blob: 293a5708d57b06a4bcb9211c26ba7ca5f1a2c5fb [file] [log] [blame]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dart_runtime_service/dart_runtime_service.dart';
import 'package:file/local.dart';
/// An outstanding write request managed by [_WriteLimiter].
class _PendingWrite {
_PendingWrite({
required this._localFs,
required this.uri,
required this.bytes,
});
final completer = Completer<void>();
final LocalFileSystem _localFs;
final Uri uri;
final Stream<List<int>> bytes;
Future<void> write() async {
final file = _localFs.file(uri);
final parentDir = file.parent;
await parentDir.create(recursive: true);
if (await file.exists()) {
await file.delete();
}
final sink = file.openWrite();
await sink.addStream(bytes);
await sink.close();
completer.complete();
_WriteLimiter._writeCompleted();
}
}
/// A utility class to schedule and limit the number of concurrent file system
/// writes as non-rooted Android devices have a very low limit for the number
/// of open files.
abstract class _WriteLimiter {
static final pendingWrites = <_PendingWrite>[];
// Artificially cap ourselves to 16.
static const _kMaxOpenWrites = 16;
static int _openWrites = 0;
static Future<void> scheduleWrite({
required LocalFileSystem localFs,
required Uri uri,
required List<int> bytes,
}) => scheduleWriteStream(
localFs: localFs,
uri: uri,
bytes: Stream.fromIterable([bytes]),
);
static Future<void> scheduleWriteStream({
required LocalFileSystem localFs,
required Uri uri,
required Stream<List<int>> bytes,
}) {
// Create a new pending write.
final pw = _PendingWrite(localFs: localFs, uri: uri, bytes: bytes);
pendingWrites.add(pw);
_maybeWriteFiles();
return pw.completer.future;
}
static void _maybeWriteFiles() {
while (_openWrites < _kMaxOpenWrites) {
if (pendingWrites.isEmpty) {
break;
}
final pw = pendingWrites.removeLast();
pw.write();
_openWrites++;
}
}
static void _writeCompleted() {
_openWrites--;
assert(_openWrites >= 0);
_maybeWriteFiles();
}
}
/// A [DevelopmentFileSystem] rooted at [rootUri], providing restricted file
/// system access to clients.
final class VMDevelopmentFileSystem extends DevelopmentFileSystem {
VMDevelopmentFileSystem({
required this._localFs,
required super.name,
required super.rootUri,
});
final LocalFileSystem _localFs;
/// Reads the contents of the file at [uri].
///
/// If the file does not exist, a [RpcException.fileDoesNotExist] exception
/// is thrown.
@override
Future<RpcResponse> readFile({required String uri}) async {
try {
final bytes = await _localFs
.file(resolve(method: '_readDevFSFile', uri: uri))
.readAsBytes();
// TODO(bkonyi): create package:vm_service type if we make this public.
return {'type': 'FSFile', 'fileContents': base64.encode(bytes)};
} on PathNotFoundException catch (e) {
RpcException.fileDoesNotExist.throwExceptionWithDetails(
details: '_readDevFSFile: $e',
);
}
}
/// Writes [bytes] to [uri].
@override
Future<void> writeFile({
required String uri,
required List<int> bytes,
}) async {
await _WriteLimiter.scheduleWrite(
localFs: _localFs,
uri: resolve(method: '_writeDevFSFile', uri: uri),
bytes: bytes,
);
}
/// Writes a stream of [bytes] to [uri].
@override
Future<void> writeStreamFile({
required String uri,
required Stream<List<int>> bytes,
}) async {
await _WriteLimiter.scheduleWriteStream(
localFs: _localFs,
uri: resolve(method: '_writeDevFSFile', uri: uri),
bytes: bytes,
);
}
/// Lists all files contained in the [DevelopmentFileSystem].
///
/// Each file is reported with its size in bytes and last modified timestamp
/// in milliseconds since epoch.
@override
Future<RpcResponse> listFiles() async {
final dir = _localFs.directory(rootUri);
final dirPathStr = dir.path;
final stream = dir.list(recursive: true);
final files = <Map<String, Object?>>[];
await for (final fileEntity in stream) {
final filePath = Uri.file(fileEntity.path).path;
final stat = await fileEntity.stat();
if (stat.type == FileSystemEntityType.file &&
filePath.startsWith(dirPathStr)) {
files.add(<String, Object?>{
// Remove any url-encoding in the filenames.
'name': Uri.decodeFull('/${filePath.substring(dirPathStr.length)}'),
'size': stat.size,
'modified': stat.modified.millisecondsSinceEpoch,
});
}
}
// TODO(bkonyi): create package:vm_service type if we make this public.
return <String, Object?>{'type': 'FSFileList', 'files': files};
}
}
/// A collection of [DevelopmentFileSystem]s.
final class VMDevelopmentFileSystemCollection
extends DevelopmentFileSystemCollection {
/// Creates a [DevFS] instance with a [VMDevelopmentFileSystemCollection]
/// backend.
static DevFS<VMDevelopmentFileSystemCollection> createDevFS() =>
DevFS(fileSystems: VMDevelopmentFileSystemCollection());
final _fsMap = <String, VMDevelopmentFileSystem>{};
final _localFs = const LocalFileSystem();
@override
List<String> get fsNames => _fsMap.keys.toList();
/// Destroys all [DevelopmentFileSystem]s in the collection.
@override
Future<void> cleanup() async {
await Future.wait(<Future<void>>[
for (final fs in _fsMap.values)
_localFs.directory(fs.rootUri).delete(recursive: true),
]);
_fsMap.clear();
}
/// Creates a new [DevelopmentFileSystem] named [name].
///
/// Throws a [RpcException.fileSystemAlreadyExists] if the file system has
/// already been created.
@override
Future<DevelopmentFileSystem> createFileSystem({required String name}) async {
if (_fsMap.containsKey(name)) {
RpcException.fileSystemAlreadyExists.throwExceptionWithDetails(
details: "_createDevFS: file system '$name' already exists",
);
}
final temp = await _localFs.systemTempDirectory.createTemp(name);
final uri = (await temp.childDirectory(name).create()).uri;
return _fsMap[name] = VMDevelopmentFileSystem(
localFs: _localFs,
name: name,
rootUri: uri,
);
}
/// Destroys the [DevelopmentFileSystem] with name [name].
///
/// Throws a [RpcException.fileSystemDoesNotExist] if the file system does
/// not exist.
@override
Future<void> deleteFileSystem({required String name}) async {
final fs = _fsMap.remove(name);
if (fs == null) {
RpcException.fileSystemDoesNotExist.throwExceptionWithDetails(
details: "_deleteDevFS: file system '$name' does not exist",
);
}
await _localFs.directory(fs.rootUri).delete(recursive: true);
}
/// Retrieves an existing [DevelopmentFileSystem] based on a JSON-RPC
/// request.
///
/// Throws a [RpcException.fileSystemDoesNotExist] if the file system does
/// not exist.
@override
DevelopmentFileSystem getFileSystem({required String name}) {
final fs = _fsMap[name];
if (fs == null) {
RpcException.fileSystemDoesNotExist.throwException();
}
return fs;
}
}