diff --git a/experimental/web_file_system/README.md b/experimental/web_file_system/README.md index 83d2677..e4ad191 100644 --- a/experimental/web_file_system/README.md +++ b/experimental/web_file_system/README.md @@ -109,8 +109,63 @@ print(canonicalPath); // "/documents/report.txt" --- -## Performance Notes: Inode vs Path-based Renames - -Most browser file systems index files by their full path strings (e.g., keying a database table by `/documents/photos/holiday.png`). Renaming the directory `/documents` to `/archive` requires iterating over every nested path and rewriting their keys, which is an $O(N)$ operation where $N$ is the number of recursive items. - `web_file_system` implements an **inode index** model. Inodes reference parents by their database IDs rather than paths. Renaming a directory only updates the single directory node's `name` property. Its child files and directories remain untouched because they continue to reference the same unchanged parent inode ID. This makes directory renaming an **$O(1)$** operation. + +--- + +## Synchronous API Support (Web Workers Only) + +Because JavaScript and Dart run on a single-threaded event loop, blocking the main browser thread is not permitted. Therefore, standard synchronous APIs (such as `readAsBytesSync`, `writeAsBytesSync`, `existsSync`, `createSync`, `deleteSync`, `listSync`, `renameSync`, and `statSync`) will throw an `UnsupportedError` if invoked on the main application thread. + +However, synchronous operations are **fully supported within Web Workers**. By delegating asynchronous work (IndexedDB metadata updates and Origin Private File System operations) to the main thread via a `SharedArrayBuffer` and blocking the Web Worker's execution loop using `Atomics.wait()`, you can safely use synchronous file system calls inside your workers. + +### Prerequisites & Security Headers + +To use the synchronous APIs, the browser requires cross-origin isolation. You must serve your web application with the following HTTP headers: + +```http +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +``` + +If these headers are not present, `SharedArrayBuffer` is disabled by the browser, and synchronous operations will throw a `StateError`. + +### Setup Example + +#### 1. Main Application Thread + +Initialize the file system and register the worker proxy to listen to RPC requests from your worker: + +```dart +import 'dart:html'; // or standard JS interop / package:web +import 'package:web_file_system/web_file_system.dart'; + +void main() { + final fs = WebFileSystem(); + final worker = Worker('worker.js'); + + // Register the proxy to handle synchronous RPC requests + WebFileSystem.registerWorkerProxy(worker, fs); +} +``` + +#### 2. Web Worker Context (`worker.js` / Dart Worker) + +In your worker code, once initialized with the `SharedArrayBuffer`, you can access the file system synchronously: + +```dart +import 'package:web_file_system/web_file_system.dart'; + +void main() { + final fs = WebFileSystem(); + + // These operations block synchronous execution inside the Web Worker + final file = fs.file('/data.bin'); + file.createSync(); + file.writeAsBytesSync([1, 2, 3, 4]); + + final bytes = file.readAsBytesSync(); + print(bytes); // [1, 2, 3, 4] +} +``` + diff --git a/experimental/web_file_system/lib/src/backend/sync_rpc_helper.dart b/experimental/web_file_system/lib/src/backend/sync_rpc_helper.dart new file mode 100644 index 0000000..86e605a --- /dev/null +++ b/experimental/web_file_system/lib/src/backend/sync_rpc_helper.dart @@ -0,0 +1,234 @@ +import 'dart:js_interop'; +import 'dart:typed_data'; + +@JS('eval') +external JSAny? _jsEval(String code); + +@JS('globalThis.registerVFSWorkerProxy') +external void _registerVFSWorkerProxy(JSAny worker, JSAny asyncFS); + +@JS('globalThis.initVFSSyncWorker') +external void _initVFSSyncWorker(); + +@JS('globalThis.sendVFSSyncRequest') +external JSUint8Array _sendVFSSyncRequest(int cmd, JSUint8Array requestBytes); + +@JS('globalThis.isVFSSyncWorkerInitialized') +external bool? get _isVFSSyncWorkerInitialized; + +@JS('globalThis.SharedArrayBuffer') +external JSAny? get _sharedArrayBufferClass; + +class SyncRpcHelper { + static bool get isSharedArrayBufferSupported { + try { + return _sharedArrayBufferClass != null; + } catch (_) { + return false; + } + } + + static bool get isWorker { + // In a worker, globalThis.document is undefined and globalThis.importScripts is defined + try { + final isWorkerResult = _jsEval("typeof importScripts !== 'undefined'"); + return (isWorkerResult as JSBoolean).toDart; + } catch (_) { + return false; + } + } + + static void injectHelperScripts() { + _jsEval(''' + if (typeof globalThis.registerVFSWorkerProxy === 'undefined') { + globalThis.registerVFSWorkerProxy = function(worker, asyncFS) { + let sab; + let statusArray; + let payloadArray; + + worker.addEventListener('message', async function(e) { + if (!e.data) return; + if (e.data.type === 'INIT_SYNC_VFS') { + sab = e.data.buffer; + statusArray = new Int32Array(sab); + payloadArray = new Uint8Array(sab); + return; + } + if (e.data.type === 'SYNC_REQ') { + const cmd = statusArray[1]; + const reqLen = statusArray[2]; + const decoder = new TextDecoder(); + const reqBytes = payloadArray.subarray(64, 64 + reqLen); + + try { + let result; + if (cmd === 1) { // exists + const path = decoder.decode(reqBytes); + const type = await asyncFS.type(path); + result = type.toString() !== 'FileSystemEntityType.notFound'; + payloadArray[64] = result ? 1 : 0; + statusArray[3] = 1; + } else if (cmd === 2) { // type + const followLinks = reqBytes[0] === 1; + const path = decoder.decode(reqBytes.subarray(1)); + const type = await asyncFS.type(path, { followLinks }); + const typeStr = type.toString().split('.').pop(); + const typeBytes = new TextEncoder().encode(typeStr); + payloadArray.set(typeBytes, 64); + statusArray[3] = typeBytes.length; + } else if (cmd === 3) { // readBytes + const path = decoder.decode(reqBytes); + const bytes = await asyncFS.file(path).readAsBytes(); + payloadArray.set(bytes, 64); + statusArray[3] = bytes.length; + } else if (cmd === 4) { // writeBytes + const view = new DataView(reqBytes.buffer, reqBytes.byteOffset, reqBytes.byteLength); + const pathLen = view.getUint32(0, true); + const path = decoder.decode(reqBytes.subarray(4, 4 + pathLen)); + const content = reqBytes.subarray(4 + pathLen); + await asyncFS.file(path).writeAsBytes(content); + statusArray[3] = 0; + } else if (cmd === 5) { // createDir + const path = decoder.decode(reqBytes); + await asyncFS.directory(path).create(recursive: true); + statusArray[3] = 0; + } else if (cmd === 6) { // delete + const path = decoder.decode(reqBytes); + await asyncFS.file(path).delete(recursive: true); + statusArray[3] = 0; + } else if (cmd === 7) { // createLink + const view = new DataView(reqBytes.buffer, reqBytes.byteOffset, reqBytes.byteLength); + const pathLen = view.getUint32(0, true); + const path = decoder.decode(reqBytes.subarray(4, 4 + pathLen)); + const target = decoder.decode(reqBytes.subarray(4 + pathLen)); + await asyncFS.link(path).create(target); + statusArray[3] = 0; + } else if (cmd === 8) { // readLink + const path = decoder.decode(reqBytes); + const target = await asyncFS.link(path).target(); + const targetBytes = new TextEncoder().encode(target); + payloadArray.set(targetBytes, 64); + statusArray[3] = targetBytes.length; + } else if (cmd === 9) { // stat + const path = decoder.decode(reqBytes); + const stat = await asyncFS.stat(path); + const statData = { + type: stat.type.toString().split('.').pop(), + size: stat.size, + modified: stat.modified.millisecondsSinceEpoch + }; + const statBytes = new TextEncoder().encode(JSON.stringify(statData)); + payloadArray.set(statBytes, 64); + statusArray[3] = statBytes.length; + } else if (cmd === 10) { // list + const path = decoder.decode(reqBytes); + const list = await asyncFS.directory(path).list(recursive: false, followLinks: false).toList(); + const entities = list.map(e => ({ + path: e.path, + type: e.runtimeType.toString().toLowerCase().replace('impl', '').replace('web', '') + })); + const listBytes = new TextEncoder().encode(JSON.stringify(entities)); + payloadArray.set(listBytes, 64); + statusArray[3] = listBytes.length; + } else if (cmd === 11) { // resolveSymbolicLinks + const path = decoder.decode(reqBytes); + const resolved = await asyncFS.resolveSymbolicLinks(path); + const resolvedBytes = new TextEncoder().encode(resolved); + payloadArray.set(resolvedBytes, 64); + statusArray[3] = resolvedBytes.length; + } else if (cmd === 12) { // rename + const view = new DataView(reqBytes.buffer, reqBytes.byteOffset, reqBytes.byteLength); + const pathLen = view.getUint32(0, true); + const path = decoder.decode(reqBytes.subarray(4, 4 + pathLen)); + const newPath = decoder.decode(reqBytes.subarray(4 + pathLen)); + + const inode = await asyncFS.resolvepath(path); + const newParentDir = asyncFS.path.dirname(newPath); + const newName = asyncFS.path.basename(newPath); + const newParentInode = await asyncFS.resolvepath(newParentDir); + + inode.parentId = newParentInode.id; + inode.name = newName; + inode.modified = Date.now(); + await asyncFS.idb.updateInode(inode); + statusArray[3] = 0; + } else if (cmd === 13) { // updateLink + const view = new DataView(reqBytes.buffer, reqBytes.byteOffset, reqBytes.byteLength); + const pathLen = view.getUint32(0, true); + const path = decoder.decode(reqBytes.subarray(4, 4 + pathLen)); + const target = decoder.decode(reqBytes.subarray(4 + pathLen)); + await asyncFS.link(path).update(target); + statusArray[3] = 0; + } + + Atomics.store(statusArray, 0, 2); // completed + } catch (err) { + console.error("VFS Proxy Error:", err); + const errBytes = new TextEncoder().encode(err.toString()); + payloadArray.set(errBytes, 64); + statusArray[3] = errBytes.length; + Atomics.store(statusArray, 0, 3); // error + } + Atomics.notify(statusArray, 0); + } + }); + }; + } + + if (typeof globalThis.initVFSSyncWorker === 'undefined') { + globalThis.isVFSSyncWorkerInitialized = false; + globalThis.initVFSSyncWorker = function() { + if (globalThis.isVFSSyncWorkerInitialized) return; + const sab = new SharedArrayBuffer(1024 * 1024 * 10); // 10MB + const statusArray = new Int32Array(sab); + const payloadArray = new Uint8Array(sab); + + globalThis.postMessage({ type: 'INIT_SYNC_VFS', buffer: sab }); + + globalThis.sendVFSSyncRequest = function(cmd, requestBytes) { + while (Atomics.load(statusArray, 0) !== 0) { + // Idle wait + } + statusArray[1] = cmd; + statusArray[2] = requestBytes.length; + payloadArray.set(requestBytes, 64); + + Atomics.store(statusArray, 0, 1); + globalThis.postMessage({ type: 'SYNC_REQ' }); + + Atomics.wait(statusArray, 0, 1); + + const status = Atomics.load(statusArray, 0); + const respLen = statusArray[3]; + const respBytes = payloadArray.slice(64, 64 + respLen); + + Atomics.store(statusArray, 0, 0); + + if (status === 3) { + throw new Error(new TextDecoder().decode(respBytes)); + } + return respBytes; + }; + globalThis.isVFSSyncWorkerInitialized = true; + }; + } + '''); + } + + static void registerWorkerProxy(JSAny worker, JSAny asyncFS) { + injectHelperScripts(); + _registerVFSWorkerProxy(worker, asyncFS); + } + + static void initSyncWorker() { + injectHelperScripts(); + _initVFSSyncWorker(); + } + + static Uint8List sendSyncRequest(int cmd, Uint8List requestBytes) { + if (_isVFSSyncWorkerInitialized != true) { + initSyncWorker(); + } + return _sendVFSSyncRequest(cmd, requestBytes.toJS).toDart; + } +} diff --git a/experimental/web_file_system/lib/src/entities/web_directory.dart b/experimental/web_file_system/lib/src/entities/web_directory.dart index 5ab0364..35a26b3 100644 --- a/experimental/web_file_system/lib/src/entities/web_directory.dart +++ b/experimental/web_file_system/lib/src/entities/web_directory.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; import 'package:file/file.dart'; import 'package:web_file_system/src/backend/idb_inode_service.dart'; import '../web_file_system.dart'; @@ -61,7 +63,27 @@ class WebDirectory extends FileSystemEntity implements Directory { @override void createSync({bool recursive = false}) { - throw UnsupportedError('Sync create not supported'); + if (existsSync()) return; + + final parentPath = _fs.path.dirname(path); + final parentType = _fs.typeSync(parentPath); + if (parentType == FileSystemEntityType.notFound) { + if (recursive) { + _fs.directory(parentPath).createSync(recursive: true); + } else { + throw FileSystemException( + 'Cannot create directory, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + } else if (parentType != FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot create directory, path = \'$path\' (OS Error: Not a directory, errno = 20)', + path, + ); + } + + _fs.makeSyncCall(5, utf8.encode(path)); } @override @@ -80,7 +102,15 @@ class WebDirectory extends FileSystemEntity implements Directory { @override Directory createTempSync([String? prefix]) { - throw UnsupportedError('Sync not supported'); + if (!existsSync()) { + throw FileSystemException( + 'Directory does not exist', path, const OSError('ENOENT', 2)); + } + final name = (prefix ?? 'temp') + _fs.uuid.v4(); + final tempDir = _fs.path.join(path, name); + final dir = WebDirectory(_fs, tempDir); + dir.createSync(); + return dir; } @override @@ -109,8 +139,34 @@ class WebDirectory extends FileSystemEntity implements Directory { } @override - void deleteSync({bool recursive = false}) => - throw UnsupportedError('Sync not supported'); + void deleteSync({bool recursive = false}) { + final type = _fs.typeSync(path, followLinks: false); + if (type == FileSystemEntityType.notFound) { + throw FileSystemException( + 'Cannot delete directory, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + if (type != FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot delete directory, path = \'$path\' (OS Error: Not a directory, errno = 20)', + path, + ); + } + + if (!recursive) { + final children = listSync(recursive: false, followLinks: false); + if (children.isNotEmpty) { + throw FileSystemException( + 'Directory not empty', + path, + const OSError('ENOTEMPTY', 39), + ); + } + } + + _fs.makeSyncCall(6, utf8.encode(path)); + } @override Future exists() async { @@ -123,7 +179,9 @@ class WebDirectory extends FileSystemEntity implements Directory { } @override - bool existsSync() => throw UnsupportedError('Sync not supported'); + bool existsSync() { + return _fs.typeSync(path, followLinks: false) == FileSystemEntityType.directory; + } @override Stream list( @@ -173,7 +231,52 @@ class WebDirectory extends FileSystemEntity implements Directory { @override List listSync( {bool recursive = false, bool followLinks = true}) { - throw UnsupportedError('Sync list not supported'); + if (!existsSync()) { + throw FileSystemException( + 'Directory not found', path, const OSError('ENOENT', 2)); + } + + final respBytes = _fs.makeSyncCall(10, utf8.encode(path)); + final listJson = json.decode(utf8.decode(respBytes)) as List; + + final List results = []; + for (final item in listJson) { + final itemMap = item as Map; + final childPath = itemMap['path'] as String; + final typeStr = itemMap['type'] as String; + + if (typeStr == 'directory') { + final dir = WebDirectory(_fs, childPath); + results.add(dir); + if (recursive) { + results.addAll(dir.listSync(recursive: true, followLinks: followLinks)); + } + } else if (typeStr == 'link') { + if (!followLinks) { + results.add(WebLink(_fs, childPath)); + } else { + try { + final resolvedType = _fs.typeSync(childPath, followLinks: true); + if (resolvedType == FileSystemEntityType.directory) { + final dir = WebDirectory(_fs, childPath); + results.add(dir); + if (recursive) { + results.addAll(dir.listSync(recursive: true, followLinks: true)); + } + } else if (resolvedType == FileSystemEntityType.notFound) { + results.add(WebLink(_fs, childPath)); + } else { + results.add(WebFile(_fs, childPath)); + } + } catch (_) { + results.add(WebLink(_fs, childPath)); + } + } + } else { + results.add(WebFile(_fs, childPath)); + } + } + return results; } @override @@ -198,8 +301,33 @@ class WebDirectory extends FileSystemEntity implements Directory { } @override - Directory renameSync(String newPath) => - throw UnsupportedError('Sync not supported'); + Directory renameSync(String newPath) { + final type = _fs.typeSync(path, followLinks: false); + if (type == FileSystemEntityType.notFound) { + throw FileSystemException( + 'Cannot rename directory, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + final newParentDir = _fs.path.dirname(newPath); + if (_fs.typeSync(newParentDir) != FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot rename directory, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + + final pathBytes = utf8.encode(path); + final newPathBytes = utf8.encode(newPath); + final request = Uint8List(4 + pathBytes.length + newPathBytes.length); + final bd = ByteData.sublistView(request); + bd.setUint32(0, pathBytes.length, Endian.little); + request.setRange(4, 4 + pathBytes.length, pathBytes); + request.setRange(4 + pathBytes.length, request.length, newPathBytes); + + _fs.makeSyncCall(12, request); + return WebDirectory(_fs, newPath); + } @override String get basename => _fs.path.basename(path); @@ -220,14 +348,13 @@ class WebDirectory extends FileSystemEntity implements Directory { Future stat() => _fs.stat(path); @override - FileStat statSync() => throw UnsupportedError('Sync not supported'); + FileStat statSync() => _fs.statSync(path); @override Future resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path); @override - String resolveSymbolicLinksSync() => - throw UnsupportedError('Sync not supported'); + String resolveSymbolicLinksSync() => _fs.resolveSymbolicLinksSync(path); @override Stream watch( diff --git a/experimental/web_file_system/lib/src/entities/web_file.dart b/experimental/web_file_system/lib/src/entities/web_file.dart index 13a0122..87911db 100644 --- a/experimental/web_file_system/lib/src/entities/web_file.dart +++ b/experimental/web_file_system/lib/src/entities/web_file.dart @@ -52,8 +52,38 @@ class WebFile extends FileSystemEntity implements File { } @override - void createSync({bool recursive = false, bool exclusive = false}) => - throw UnsupportedError('Sync not supported'); + void createSync({bool recursive = false, bool exclusive = false}) { + if (existsSync()) { + if (exclusive) { + throw FileSystemException( + 'File already exists', + path, + const OSError('EEXIST', 17), + ); + } + return; + } + + final parentPath = _fs.path.dirname(path); + final parentType = _fs.typeSync(parentPath); + if (parentType == FileSystemEntityType.notFound) { + if (recursive) { + _fs.directory(parentPath).createSync(recursive: true); + } else { + throw FileSystemException( + 'Cannot create file, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + } else if (parentType != FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot create file, path = \'$path\' (OS Error: Not a directory, errno = 20)', + path, + ); + } + + writeAsBytesSync([]); + } @override Future copy(String newPath) async { @@ -76,7 +106,11 @@ class WebFile extends FileSystemEntity implements File { } @override - File copySync(String newPath) => throw UnsupportedError('Sync not supported'); + File copySync(String newPath) { + final bytes = readAsBytesSync(); + _fs.file(newPath).writeAsBytesSync(bytes); + return WebFile(_fs, newPath); + } @override Future length() async { @@ -85,7 +119,7 @@ class WebFile extends FileSystemEntity implements File { } @override - int lengthSync() => throw UnsupportedError('Sync not supported'); + int lengthSync() => statSync().size; @override Future lastModified() async { @@ -94,7 +128,7 @@ class WebFile extends FileSystemEntity implements File { } @override - DateTime lastModifiedSync() => throw UnsupportedError('Sync not supported'); + DateTime lastModifiedSync() => statSync().modified; @override Future lastAccessed() async { @@ -102,7 +136,7 @@ class WebFile extends FileSystemEntity implements File { } @override - DateTime lastAccessedSync() => throw UnsupportedError('Sync not supported'); + DateTime lastAccessedSync() => statSync().accessed; @override Future setLastAccessed(DateTime time) async {} @@ -190,7 +224,27 @@ class WebFile extends FileSystemEntity implements File { FileMode mode = FileMode.write, bool flush = false, }) { - throw UnsupportedError('Sync not supported'); + if (mode == FileMode.append) { + throw UnsupportedError('Append not yet optimized'); + } + + final parentPath = _fs.path.dirname(path); + if (_fs.typeSync(parentPath) != FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot open file, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + + final pathBytes = utf8.encode(path); + final pathLen = pathBytes.length; + final request = Uint8List(4 + pathLen + bytes.length); + final bd = ByteData.sublistView(request); + bd.setUint32(0, pathLen, Endian.little); + request.setRange(4, 4 + pathLen, pathBytes); + request.setRange(4 + pathLen, request.length, bytes); + + _fs.makeSyncCall(4, request); } @override @@ -210,7 +264,7 @@ class WebFile extends FileSystemEntity implements File { Encoding encoding = utf8, bool flush = false, }) { - throw UnsupportedError('Sync not supported'); + writeAsBytesSync(encoding.encode(contents), mode: mode, flush: flush); } @override @@ -280,7 +334,22 @@ class WebFile extends FileSystemEntity implements File { } @override - Uint8List readAsBytesSync() => throw UnsupportedError('Sync not supported'); + Uint8List readAsBytesSync() { + final type = _fs.typeSync(path); + if (type == FileSystemEntityType.notFound) { + throw FileSystemException( + 'Cannot open file, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + if (type == FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot open file, path = \'$path\' (OS Error: Is a directory, errno = 21)', + path, + ); + } + return _fs.makeSyncCall(3, utf8.encode(path)); + } @override Future readAsString({Encoding encoding = utf8}) async { @@ -289,18 +358,21 @@ class WebFile extends FileSystemEntity implements File { } @override - String readAsStringSync({Encoding encoding = utf8}) => - throw UnsupportedError('Sync not supported'); + String readAsStringSync({Encoding encoding = utf8}) { + return encoding.decode(readAsBytesSync()); + } @override Future> readAsLines({Encoding encoding = utf8}) async { final str = await readAsString(encoding: encoding); - return str.split('\n'); + return const LineSplitter().convert(str); } @override - List readAsLinesSync({Encoding encoding = utf8}) => - throw UnsupportedError('Sync not supported'); + List readAsLinesSync({Encoding encoding = utf8}) { + final str = readAsStringSync(encoding: encoding); + return const LineSplitter().convert(str); + } @override Future exists() async { @@ -313,7 +385,9 @@ class WebFile extends FileSystemEntity implements File { } @override - bool existsSync() => throw UnsupportedError('Sync not supported'); + bool existsSync() { + return _fs.typeSync(path, followLinks: false) == FileSystemEntityType.file; + } @override Future rename(String newPath) async { @@ -337,8 +411,33 @@ class WebFile extends FileSystemEntity implements File { } @override - File renameSync(String newPath) => - throw UnsupportedError('Sync not supported'); + File renameSync(String newPath) { + final type = _fs.typeSync(path, followLinks: false); + if (type == FileSystemEntityType.notFound) { + throw FileSystemException( + 'Cannot rename file, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + final newParentDir = _fs.path.dirname(newPath); + if (_fs.typeSync(newParentDir) != FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot rename file, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + + final pathBytes = utf8.encode(path); + final newPathBytes = utf8.encode(newPath); + final request = Uint8List(4 + pathBytes.length + newPathBytes.length); + final bd = ByteData.sublistView(request); + bd.setUint32(0, pathBytes.length, Endian.little); + request.setRange(4, 4 + pathBytes.length, pathBytes); + request.setRange(4 + pathBytes.length, request.length, newPathBytes); + + _fs.makeSyncCall(12, request); + return WebFile(_fs, newPath); + } @override Future delete({bool recursive = false}) async { @@ -348,14 +447,22 @@ class WebFile extends FileSystemEntity implements File { } @override - void deleteSync({bool recursive = false}) => - throw UnsupportedError('Sync not supported'); + void deleteSync({bool recursive = false}) { + final type = _fs.typeSync(path, followLinks: false); + if (type == FileSystemEntityType.notFound) { + throw FileSystemException( + 'Cannot delete file, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + _fs.makeSyncCall(6, utf8.encode(path)); + } @override Future stat() => _fs.stat(path); @override - FileStat statSync() => throw UnsupportedError('Sync not supported'); + FileStat statSync() => _fs.statSync(path); @override Uri get uri => Uri.parse(path); @@ -374,8 +481,7 @@ class WebFile extends FileSystemEntity implements File { Future resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path); @override - String resolveSymbolicLinksSync() => - throw UnsupportedError('Sync not supported'); + String resolveSymbolicLinksSync() => _fs.resolveSymbolicLinksSync(path); @override Stream watch({ diff --git a/experimental/web_file_system/lib/src/entities/web_link.dart b/experimental/web_file_system/lib/src/entities/web_link.dart index 1de3393..9c3a156 100644 --- a/experimental/web_file_system/lib/src/entities/web_link.dart +++ b/experimental/web_file_system/lib/src/entities/web_link.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:file/file.dart'; import 'package:web_file_system/src/backend/idb_inode_service.dart'; import '../web_file_system.dart'; @@ -57,7 +58,41 @@ class WebLink extends FileSystemEntity implements Link { @override void createSync(String target, {bool recursive = false}) { - throw UnsupportedError('Sync not supported'); + if (existsSync()) { + throw FileSystemException( + 'Link already exists', + path, + const OSError('EEXIST', 17), + ); + } + + final parentPath = _fs.path.dirname(path); + final parentType = _fs.typeSync(parentPath); + if (parentType == FileSystemEntityType.notFound) { + if (recursive) { + _fs.directory(parentPath).createSync(recursive: true); + } else { + throw FileSystemException( + 'Cannot create link, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + } else if (parentType != FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot create link, path = \'$path\' (OS Error: Not a directory, errno = 20)', + path, + ); + } + + final pathBytes = utf8.encode(path); + final targetBytes = utf8.encode(target); + final request = Uint8List(4 + pathBytes.length + targetBytes.length); + final bd = ByteData.sublistView(request); + bd.setUint32(0, pathBytes.length, Endian.little); + request.setRange(4, 4 + pathBytes.length, pathBytes); + request.setRange(4 + pathBytes.length, request.length, targetBytes); + + _fs.makeSyncCall(7, request); } @override @@ -85,7 +120,30 @@ class WebLink extends FileSystemEntity implements Link { @override void updateSync(String target) { - throw UnsupportedError('Sync not supported'); + final type = _fs.typeSync(path, followLinks: false); + if (type == FileSystemEntityType.notFound) { + throw FileSystemException( + 'Cannot update link, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + if (type != FileSystemEntityType.link) { + throw FileSystemException( + 'Not a link', + path, + const OSError('EINVAL', 22), + ); + } + + final pathBytes = utf8.encode(path); + final targetBytes = utf8.encode(target); + final request = Uint8List(4 + pathBytes.length + targetBytes.length); + final bd = ByteData.sublistView(request); + bd.setUint32(0, pathBytes.length, Endian.little); + request.setRange(4, 4 + pathBytes.length, pathBytes); + request.setRange(4 + pathBytes.length, request.length, targetBytes); + + _fs.makeSyncCall(13, request); } @override @@ -107,7 +165,22 @@ class WebLink extends FileSystemEntity implements Link { @override String targetSync() { - throw UnsupportedError('Sync not supported'); + final type = _fs.typeSync(path, followLinks: false); + if (type == FileSystemEntityType.notFound) { + throw FileSystemException( + 'Cannot read link, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + if (type != FileSystemEntityType.link) { + throw FileSystemException( + 'Not a link', + path, + const OSError('EINVAL', 22), + ); + } + final respBytes = _fs.makeSyncCall(8, utf8.encode(path)); + return utf8.decode(respBytes); } @override @@ -132,8 +205,33 @@ class WebLink extends FileSystemEntity implements Link { } @override - Link renameSync(String newPath) => - throw UnsupportedError('Sync not supported'); + Link renameSync(String newPath) { + final type = _fs.typeSync(path, followLinks: false); + if (type == FileSystemEntityType.notFound) { + throw FileSystemException( + 'Cannot rename link, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + final newParentDir = _fs.path.dirname(newPath); + if (_fs.typeSync(newParentDir) != FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot rename link, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + + final pathBytes = utf8.encode(path); + final newPathBytes = utf8.encode(newPath); + final request = Uint8List(4 + pathBytes.length + newPathBytes.length); + final bd = ByteData.sublistView(request); + bd.setUint32(0, pathBytes.length, Endian.little); + request.setRange(4, 4 + pathBytes.length, pathBytes); + request.setRange(4 + pathBytes.length, request.length, newPathBytes); + + _fs.makeSyncCall(12, request); + return WebLink(_fs, newPath); + } @override Future delete({bool recursive = false}) async { @@ -143,8 +241,16 @@ class WebLink extends FileSystemEntity implements Link { } @override - void deleteSync({bool recursive = false}) => - throw UnsupportedError('Sync not supported'); + void deleteSync({bool recursive = false}) { + final type = _fs.typeSync(path, followLinks: false); + if (type == FileSystemEntityType.notFound) { + throw FileSystemException( + 'Cannot delete link, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + _fs.makeSyncCall(6, utf8.encode(path)); + } @override Future exists() async { @@ -157,13 +263,15 @@ class WebLink extends FileSystemEntity implements Link { } @override - bool existsSync() => throw UnsupportedError('Sync not supported'); + bool existsSync() { + return _fs.typeSync(path, followLinks: false) == FileSystemEntityType.link; + } @override Future stat() async => (await _fs.stat(path)); @override - FileStat statSync() => throw UnsupportedError('Sync not supported'); + FileStat statSync() => _fs.statSync(path); @override Uri get uri => Uri.parse(path); @@ -187,8 +295,7 @@ class WebLink extends FileSystemEntity implements Link { Future resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path); @override - String resolveSymbolicLinksSync() => - throw UnsupportedError('Sync not supported'); + String resolveSymbolicLinksSync() => _fs.resolveSymbolicLinksSync(path); @override Stream watch({ diff --git a/experimental/web_file_system/lib/src/web_file_system.dart b/experimental/web_file_system/lib/src/web_file_system.dart index fd934ea..54b4708 100644 --- a/experimental/web_file_system/lib/src/web_file_system.dart +++ b/experimental/web_file_system/lib/src/web_file_system.dart @@ -1,10 +1,13 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:js_interop'; import 'package:file/file.dart'; import 'package:path/path.dart' as p; import 'package:uuid/uuid.dart'; import 'package:web_file_system/src/backend/idb_inode_service.dart'; import 'package:web_file_system/src/backend/opfs_block_store.dart'; +import 'package:web_file_system/src/backend/sync_rpc_helper.dart'; import 'entities/web_directory.dart'; import 'entities/web_file.dart'; import 'entities/web_link.dart'; @@ -60,9 +63,43 @@ class WebFileSystem extends FileSystem { } } + static void registerWorkerProxy(dynamic worker, WebFileSystem fs) { + SyncRpcHelper.registerWorkerProxy(worker as JSAny, fs as JSAny); + } + + Uint8List makeSyncCall(int cmd, Uint8List request) { + if (!SyncRpcHelper.isWorker) { + throw UnsupportedError('Synchronous operations are only supported inside Web Workers.'); + } + if (!SyncRpcHelper.isSharedArrayBufferSupported) { + throw StateError('SharedArrayBuffer is not supported. Ensure your site is cross-origin isolated with COOP/COEP headers.'); + } + return SyncRpcHelper.sendSyncRequest(cmd, request); + } + @override FileSystemEntityType typeSync(String path, {bool followLinks = true}) { - throw UnsupportedError('Sync type not supported'); + if (!SyncRpcHelper.isWorker) { + throw UnsupportedError('Synchronous operations are only supported inside Web Workers.'); + } + if (!SyncRpcHelper.isSharedArrayBufferSupported) { + throw StateError('SharedArrayBuffer is not supported. Ensure your site is cross-origin isolated with COOP/COEP headers.'); + } + try { + final pathBytes = utf8.encode(path); + final payload = Uint8List(pathBytes.length + 1); + payload[0] = followLinks ? 1 : 0; + payload.setRange(1, payload.length, pathBytes); + + final respBytes = makeSyncCall(2, payload); + final typeStr = utf8.decode(respBytes); + if (typeStr == 'file') return FileSystemEntityType.file; + if (typeStr == 'directory') return FileSystemEntityType.directory; + if (typeStr == 'link') return FileSystemEntityType.link; + return FileSystemEntityType.notFound; + } catch (_) { + return FileSystemEntityType.notFound; + } } // Internal Resolution Logic @@ -176,7 +213,34 @@ class WebFileSystem extends FileSystem { @override FileStat statSync(String path) { - throw UnsupportedError('Sync stat not supported'); + if (!SyncRpcHelper.isWorker) { + throw UnsupportedError('Synchronous operations are only supported inside Web Workers.'); + } + if (!SyncRpcHelper.isSharedArrayBufferSupported) { + throw StateError('SharedArrayBuffer is not supported. Ensure your site is cross-origin isolated with COOP/COEP headers.'); + } + try { + final respBytes = makeSyncCall(9, utf8.encode(path)); + final statMap = json.decode(utf8.decode(respBytes)) as Map; + final typeStr = statMap['type'] as String; + final size = statMap['size'] as int; + final modified = statMap['modified'] as int; + + FileSystemEntityType type; + if (typeStr == 'file') { + type = FileSystemEntityType.file; + } else if (typeStr == 'directory') { + type = FileSystemEntityType.directory; + } else if (typeStr == 'link') { + type = FileSystemEntityType.link; + } else { + type = FileSystemEntityType.notFound; + } + + return FileStatImpl(modified, size, type); + } catch (_) { + return FileStatImpl(0, 0, FileSystemEntityType.notFound); + } } FileSystemEntityType _getType(int nodeType) { @@ -188,15 +252,15 @@ class WebFileSystem extends FileSystem { @override bool isFileSync(String path) => - throw UnsupportedError('Sync isFile not supported'); + typeSync(path) == FileSystemEntityType.file; @override bool isDirectorySync(String path) => - throw UnsupportedError('Sync isDirectory not supported'); + typeSync(path) == FileSystemEntityType.directory; @override bool isLinkSync(String path) => - throw UnsupportedError('Sync isLink not supported'); + typeSync(path, followLinks: false) == FileSystemEntityType.link; @override Future isFile(String path) async => @@ -226,8 +290,15 @@ class WebFileSystem extends FileSystem { } @override - bool identicalSync(String path1, String path2) => - throw UnsupportedError('Sync not supported'); + bool identicalSync(String path1, String path2) { + try { + final r1 = resolveSymbolicLinksSync(path1); + final r2 = resolveSymbolicLinksSync(path2); + return r1 == r2; + } catch (_) { + return false; + } + } Future resolveSymbolicLinks(String pathStr) async { final inode = await resolvepath(pathStr, followLinks: true); @@ -240,6 +311,11 @@ class WebFileSystem extends FileSystem { if (segments.isEmpty) return '/'; return '/' + segments.reversed.join('/'); } + + String resolveSymbolicLinksSync(String pathStr) { + final respBytes = makeSyncCall(11, utf8.encode(pathStr)); + return utf8.decode(respBytes); + } } class FileStatImpl implements FileStat { diff --git a/experimental/web_file_system/pubspec.lock b/experimental/web_file_system/pubspec.lock new file mode 100644 index 0000000..9eec6e3 --- /dev/null +++ b/experimental/web_file_system/pubspec.lock @@ -0,0 +1,406 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "563c6992eaeda8625f45b87f6a6a0c547df16565d1c93d8271c7c11057710ca7" + url: "https://pub.dev" + source: hosted + version: "101.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: aa6a9365901532864cae51208f2a6bb18dd01972ebead19c431efc848f60080b + url: "https://pub.dev" + source: hosted + version: "13.1.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: "direct main" + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mime: + dependency: "direct main" + description: + name: mime + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: "direct main" + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.5" diff --git a/experimental/web_file_system/test/web_file_system_test.dart b/experimental/web_file_system/test/web_file_system_test.dart index 68909a3..5e8f286 100644 --- a/experimental/web_file_system/test/web_file_system_test.dart +++ b/experimental/web_file_system/test/web_file_system_test.dart @@ -1,5 +1,6 @@ @TestOn('browser') import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'dart:js_interop'; import 'package:test/test.dart'; @@ -488,6 +489,493 @@ void main() { ); }); }); + + group('Sync API Mocking & Error Verification', () { + test('Synchronous APIs on main thread throw UnsupportedError', () { + final file = fs.file('/sync_main.txt'); + expect(() => fs.typeSync('/sync_main.txt'), throwsA(isA())); + expect(() => fs.statSync('/sync_main.txt'), throwsA(isA())); + expect(() => fs.resolveSymbolicLinksSync('/sync_main.txt'), throwsA(isA())); + expect(() => file.createSync(), throwsA(isA())); + expect(() => file.writeAsBytesSync([1]), throwsA(isA())); + expect(() => file.writeAsStringSync('a'), throwsA(isA())); + expect(() => file.readAsBytesSync(), throwsA(isA())); + expect(() => file.readAsStringSync(), throwsA(isA())); + expect(() => file.readAsLinesSync(), throwsA(isA())); + expect(() => file.existsSync(), throwsA(isA())); + expect(() => file.renameSync('/sync_main_renamed.txt'), throwsA(isA())); + expect(() => file.deleteSync(), throwsA(isA())); + expect(() => file.statSync(), throwsA(isA())); + + final dir = fs.directory('/sync_dir'); + expect(() => dir.createSync(), throwsA(isA())); + expect(() => dir.createTempSync(), throwsA(isA())); + expect(() => dir.deleteSync(), throwsA(isA())); + expect(() => dir.existsSync(), throwsA(isA())); + expect(() => dir.listSync(), throwsA(isA())); + expect(() => dir.renameSync('/sync_dir_renamed'), throwsA(isA())); + expect(() => dir.statSync(), throwsA(isA())); + + final link = fs.link('/sync_link'); + expect(() => link.createSync('/target'), throwsA(isA())); + expect(() => link.updateSync('/new_target'), throwsA(isA())); + expect(() => link.targetSync(), throwsA(isA())); + expect(() => link.renameSync('/sync_link_renamed'), throwsA(isA())); + expect(() => link.deleteSync(), throwsA(isA())); + expect(() => link.existsSync(), throwsA(isA())); + expect(() => link.statSync(), throwsA(isA())); + }); + + group('Mock Worker Environment', () { + setUp(() { + setupSyncMockJS(); + }); + + tearDown(() { + clearSyncMockJS(); + }); + + test('typeSync calls cmd 2 and returns correct types', () { + setMockSyncResponse(utf8.encode('file')); + expect(fs.typeSync('/test_file'), equals(FileSystemEntityType.file)); + expect(getLastSyncCmd(), equals(2)); + + setMockSyncResponse(utf8.encode('directory')); + expect(fs.typeSync('/test_dir'), equals(FileSystemEntityType.directory)); + + setMockSyncResponse(utf8.encode('link')); + expect(fs.typeSync('/test_link'), equals(FileSystemEntityType.link)); + + setMockSyncResponse(utf8.encode('notFound')); + expect(fs.typeSync('/not_found'), equals(FileSystemEntityType.notFound)); + }); + + test('file read and write sync APIs', () { + // existsSync + setMockSyncResponse(utf8.encode('file')); + final file = fs.file('/sync_worker.txt'); + expect(file.existsSync(), isTrue); + expect(getLastSyncCmd(), equals(2)); + + // writeAsBytesSync + setMockSyncResponse(utf8.encode('directory')); // parent typeSync + setMockSyncResponse([]); // writeBytes + file.writeAsBytesSync([10, 20, 30]); + expect(getLastSyncCmd(), equals(4)); + final payload = getLastSyncPayload(); + expect(payload, isNotNull); + // Request format: [pathLen (4)] + [pathBytes] + [bytes] + // path is '/sync_worker.txt' (length 16) + expect(payload![0], equals(16)); + expect(payload.sublist(4, 20), equals(utf8.encode('/sync_worker.txt'))); + expect(payload.sublist(20), equals([10, 20, 30])); + + // writeAsStringSync + setMockSyncResponse(utf8.encode('directory')); // parent typeSync + setMockSyncResponse([]); // writeBytes + file.writeAsStringSync('abc'); + expect(getLastSyncCmd(), equals(4)); + + // readAsBytesSync + setMockSyncResponse(utf8.encode('file')); // typeSync(path) + setMockSyncResponse([12, 34, 56]); // readBytes + expect(file.readAsBytesSync(), equals([12, 34, 56])); + expect(getLastSyncCmd(), equals(3)); + + // readAsStringSync + setMockSyncResponse(utf8.encode('file')); // typeSync(path) + setMockSyncResponse(utf8.encode('hello sync')); // readBytes + expect(file.readAsStringSync(), equals('hello sync')); + + // readAsLinesSync + setMockSyncResponse(utf8.encode('file')); // typeSync(path) + setMockSyncResponse(utf8.encode('line1\nline2\r\nline3')); // readBytes + expect(file.readAsLinesSync(), equals(['line1', 'line2', 'line3'])); + + // deleteSync + setMockSyncResponse(utf8.encode('file')); // typeSync(path) + setMockSyncResponse([]); // delete + file.deleteSync(); + expect(getLastSyncCmd(), equals(6)); + + // statSync + final statJson = json.encode({ + 'type': 'file', + 'size': 1500, + 'modified': 1718100000000, + }); + setMockSyncResponse(utf8.encode(statJson)); + final stat = file.statSync(); + expect(getLastSyncCmd(), equals(9)); + expect(stat.type, equals(FileSystemEntityType.file)); + expect(stat.size, equals(1500)); + expect(stat.modified.millisecondsSinceEpoch, equals(1718100000000)); + }); + + test('directory sync APIs', () { + final dir = fs.directory('/sync_dir'); + + // createSync + setMockSyncResponse(utf8.encode('notFound')); // existsSync + setMockSyncResponse(utf8.encode('directory')); // parent typeSync + setMockSyncResponse([]); // createDir + dir.createSync(); + expect(getLastSyncCmd(), equals(5)); + + // deleteSync + setMockSyncResponse(utf8.encode('directory')); // typeSync(path) inside deleteSync + setMockSyncResponse(utf8.encode('directory')); // typeSync(path) inside listSync -> existsSync + setMockSyncResponse(utf8.encode('[]')); // listSync + setMockSyncResponse([]); // delete + dir.deleteSync(); + expect(getLastSyncCmd(), equals(6)); + + // listSync + final listData = json.encode([ + {'path': '/sync_dir/file.txt', 'type': 'file'}, + {'path': '/sync_dir/sub', 'type': 'directory'}, + {'path': '/sync_dir/link', 'type': 'link'}, + ]); + setMockSyncResponse(utf8.encode('directory')); // listSync -> existsSync + setMockSyncResponse(utf8.encode(listData)); // listSync + final list = dir.listSync(followLinks: false); + expect(getLastSyncCmd(), equals(10)); + expect(list.length, equals(3)); + expect(list[0], isA()); + expect(list[0].path, equals('/sync_dir/file.txt')); + expect(list[1], isA()); + expect(list[2], isA()); + }); + + test('link sync APIs', () { + final link = fs.link('/sync_link'); + + // createSync + setMockSyncResponse(utf8.encode('notFound')); // existsSync + setMockSyncResponse(utf8.encode('directory')); // parent typeSync + setMockSyncResponse([]); // createLink + link.createSync('/target_path'); + expect(getLastSyncCmd(), equals(7)); + + // updateSync + setMockSyncResponse(utf8.encode('link')); // typeSync check + setMockSyncResponse([]); // updateLink + link.updateSync('/new_target'); + expect(getLastSyncCmd(), equals(13)); + + // targetSync + setMockSyncResponse(utf8.encode('link')); // typeSync check + setMockSyncResponse(utf8.encode('/resolved_target')); // readLink + expect(link.targetSync(), equals('/resolved_target')); + expect(getLastSyncCmd(), equals(8)); + + // deleteSync + setMockSyncResponse(utf8.encode('link')); // typeSync check + setMockSyncResponse([]); // delete + link.deleteSync(); + expect(getLastSyncCmd(), equals(6)); + }); + + test('sync error paths and branch coverage', () { + // 1. SharedArrayBuffer unsupported + jsEval('window.SharedArrayBuffer = undefined;'); + expect(() => fs.typeSync('/'), throwsStateError); + expect(() => fs.statSync('/'), throwsStateError); + jsEval('window.SharedArrayBuffer = function() {};'); // restore + + // 2. typeSync catch block + jsEval(''' + window.originalSendVFSSyncRequest = window.sendVFSSyncRequest; + window.sendVFSSyncRequest = function() { throw new Error("mock error"); }; + '''); + expect(fs.typeSync('/'), equals(FileSystemEntityType.notFound)); + expect(fs.statSync('/').type, equals(FileSystemEntityType.notFound)); + jsEval('window.sendVFSSyncRequest = window.originalSendVFSSyncRequest;'); // restore + + // 3. statSync type branches (directory, link, other/notFound) + // directory + setMockSyncResponse(utf8.encode(json.encode({'type': 'directory', 'size': 0, 'modified': 123}))); + expect(fs.statSync('/dir').type, equals(FileSystemEntityType.directory)); + // link + setMockSyncResponse(utf8.encode(json.encode({'type': 'link', 'size': 0, 'modified': 123}))); + expect(fs.statSync('/link').type, equals(FileSystemEntityType.link)); + // other + setMockSyncResponse(utf8.encode(json.encode({'type': 'unknown', 'size': 0, 'modified': 123}))); + expect(fs.statSync('/unknown').type, equals(FileSystemEntityType.notFound)); + + // 4. WebDirectory createSync branches + final dir = fs.directory('/sync_dir_cov'); + // Already exists + setMockSyncResponse(utf8.encode('directory')); // existsSync + dir.createSync(); // Should return early without exception + + // Parent notFound, recursive: true + setMockSyncResponse(utf8.encode('notFound')); // child existsSync + setMockSyncResponse(utf8.encode('notFound')); // parent typeSync + setMockSyncResponse(utf8.encode('notFound')); // parent existsSync + setMockSyncResponse(utf8.encode('directory')); // parent-parent typeSync + setMockSyncResponse([]); // parent createDir + setMockSyncResponse([]); // child createDir + dir.createSync(recursive: true); + + // Parent notFound, recursive: false throws + setMockSyncResponse(utf8.encode('notFound')); // existsSync + setMockSyncResponse(utf8.encode('notFound')); // parent typeSync + expect(() => dir.createSync(recursive: false), throwsA(isA())); + + // Parent is file throws + setMockSyncResponse(utf8.encode('notFound')); // existsSync + setMockSyncResponse(utf8.encode('file')); // parent typeSync + expect(() => dir.createSync(), throwsA(isA())); + + // createTempSync when directory notFound throws + setMockSyncResponse(utf8.encode('notFound')); // existsSync + expect(() => dir.createTempSync(), throwsA(isA())); + + // createTempSync successful path + setMockSyncResponse(utf8.encode('directory')); // existsSync + setMockSyncResponse(utf8.encode('notFound')); // temp child existsSync + setMockSyncResponse(utf8.encode('directory')); // temp parent typeSync + setMockSyncResponse([]); // temp createDir + final tempDir = dir.createTempSync('foo'); + expect(tempDir.path, contains('foo')); + + // deleteSync path notFound throws + setMockSyncResponse(utf8.encode('notFound')); // typeSync + expect(() => dir.deleteSync(), throwsA(isA())); + + // deleteSync path is file throws + setMockSyncResponse(utf8.encode('file')); // typeSync + expect(() => dir.deleteSync(), throwsA(isA())); + + // deleteSync not empty throws + setMockSyncResponse(utf8.encode('directory')); // typeSync + setMockSyncResponse(utf8.encode('directory')); // existsSync inside listSync + setMockSyncResponse(utf8.encode(json.encode([{'path': '/sub/a', 'type': 'file'}]))); // listSync + expect(() => dir.deleteSync(), throwsA(isA())); + + // listSync path notFound throws + setMockSyncResponse(utf8.encode('notFound')); // existsSync + expect(() => dir.listSync(), throwsA(isA())); + + // listSync recursive containing a directory + setMockSyncResponse(utf8.encode('directory')); // existsSync + setMockSyncResponse(utf8.encode(json.encode([{'path': '/sync_dir_cov/sub', 'type': 'directory'}]))); // listSync level 1 + setMockSyncResponse(utf8.encode('directory')); // existsSync recursive + setMockSyncResponse(utf8.encode('[]')); // listSync level 2 + final listRec = dir.listSync(recursive: true); + expect(listRec.length, equals(1)); + + // listSync following link target to directory + setMockSyncResponse(utf8.encode('directory')); // existsSync + setMockSyncResponse(utf8.encode(json.encode([{'path': '/sync_dir_cov/lnk', 'type': 'link'}]))); // listSync + setMockSyncResponse(utf8.encode('directory')); // typeSync for link target + final listLnkDir = dir.listSync(followLinks: true); + expect(listLnkDir.length, equals(1)); + expect(listLnkDir[0], isA()); + + // listSync following link target to file (non-directory) + setMockSyncResponse(utf8.encode('directory')); // existsSync + setMockSyncResponse(utf8.encode(json.encode([{'path': '/sync_dir_cov/lnk', 'type': 'link'}]))); // listSync + setMockSyncResponse(utf8.encode('file')); // typeSync for link target + final listLnkFile = dir.listSync(followLinks: true); + expect(listLnkFile.length, equals(1)); + expect(listLnkFile[0], isA()); + + // listSync following link target throws/notFound + setMockSyncResponse(utf8.encode('directory')); // existsSync + setMockSyncResponse(utf8.encode(json.encode([{'path': '/sync_dir_cov/lnk', 'type': 'link'}]))); // listSync + setMockSyncResponse(utf8.encode('notFound')); // typeSync for link target + final listLnkNotFound = dir.listSync(followLinks: true); + expect(listLnkNotFound.length, equals(1)); + expect(listLnkNotFound[0], isA()); + + // listSync recursive following link target to directory + setMockSyncResponse(utf8.encode('directory')); // existsSync + setMockSyncResponse(utf8.encode(json.encode([{'path': '/sync_dir_cov/lnk', 'type': 'link'}]))); // listSync level 1 + setMockSyncResponse(utf8.encode('directory')); // typeSync for link target + setMockSyncResponse(utf8.encode('directory')); // existsSync recursive + setMockSyncResponse(utf8.encode('[]')); // listSync level 2 + final listRecLnkDir = dir.listSync(recursive: true, followLinks: true); + expect(listRecLnkDir.length, equals(1)); + expect(listRecLnkDir[0], isA()); + + // listSync following link target where typeSync throws (covered via SAB disabled mid-run) + jsEval(''' + window.originalSendVFSSyncRequest = window.sendVFSSyncRequest; + window.sendVFSSyncRequest = function(cmd, req) { + if (cmd === 10) { + window.SharedArrayBuffer = undefined; // disable SAB + const listData = JSON.stringify([{'path': '/sync_dir_cov/lnk', 'type': 'link'}]); + const bytes = new TextEncoder().encode(listData); + return bytes; + } + return window.originalSendVFSSyncRequest(cmd, req); + }; + '''); + setMockSyncResponse(utf8.encode('directory')); // existsSync + // listSync response is provided by custom sendVFSSyncRequest function + final listLnkThrow = dir.listSync(followLinks: true); + expect(listLnkThrow.length, equals(1)); + expect(listLnkThrow[0], isA()); + jsEval(''' + window.sendVFSSyncRequest = window.originalSendVFSSyncRequest; + window.SharedArrayBuffer = function() {}; // restore SAB + '''); + + // makeSyncCall SharedArrayBuffer unsupported + jsEval('window.SharedArrayBuffer = undefined;'); + expect(() => dir.resolveSymbolicLinksSync(), throwsStateError); + jsEval('window.SharedArrayBuffer = function() {};'); // restore + + // renameSync original path not found throws + setMockSyncResponse(utf8.encode('notFound')); // typeSync + expect(() => dir.renameSync('/new'), throwsA(isA())); + + // renameSync new parent not directory throws + setMockSyncResponse(utf8.encode('directory')); // typeSync self + setMockSyncResponse(utf8.encode('file')); // typeSync parent + expect(() => dir.renameSync('/parent_file/new'), throwsA(isA())); + + // renameSync successful + setMockSyncResponse(utf8.encode('directory')); // typeSync self + setMockSyncResponse(utf8.encode('directory')); // typeSync parent + setMockSyncResponse([]); // rename + final renamedDir = dir.renameSync('/new_parent/new_dir'); + expect(renamedDir.path, equals('/new_parent/new_dir')); + + // resolveSymbolicLinksSync + setMockSyncResponse(utf8.encode('/resolved/path')); + expect(dir.resolveSymbolicLinksSync(), equals('/resolved/path')); + + // 5. WebFile sync branches + final file = fs.file('/sync_file_cov'); + // createSync already exists, exclusive: true throws + setMockSyncResponse(utf8.encode('file')); // existsSync + expect(() => file.createSync(exclusive: true), throwsA(isA())); + + // createSync already exists, exclusive: false returns early + setMockSyncResponse(utf8.encode('file')); // existsSync + file.createSync(exclusive: false); // returns early + + // createSync parent notFound, recursive: false throws + setMockSyncResponse(utf8.encode('notFound')); // existsSync + setMockSyncResponse(utf8.encode('notFound')); // parent typeSync + expect(() => file.createSync(), throwsA(isA())); + + // createSync parent notFound, recursive: true + setMockSyncResponse(utf8.encode('notFound')); // existsSync + setMockSyncResponse(utf8.encode('notFound')); // parent typeSync + setMockSyncResponse(utf8.encode('notFound')); // parent existsSync + setMockSyncResponse(utf8.encode('directory')); // parent-parent typeSync + setMockSyncResponse([]); // parent createDir + setMockSyncResponse(utf8.encode('directory')); // parent check for writeBytes + setMockSyncResponse([]); // writeBytes (create file) + file.createSync(recursive: true); + + // createSync parent is file throws + setMockSyncResponse(utf8.encode('notFound')); // existsSync + setMockSyncResponse(utf8.encode('file')); // parent typeSync + expect(() => file.createSync(), throwsA(isA())); + + // writeAsBytesSync FileMode.append throws UnsupportedError + expect(() => file.writeAsBytesSync([], mode: FileMode.append), throwsUnsupportedError); + + // writeAsBytesSync parent not directory throws + setMockSyncResponse(utf8.encode('file')); // parent typeSync + expect(() => file.writeAsBytesSync([]), throwsA(isA())); + + // readAsBytesSync path notFound throws + setMockSyncResponse(utf8.encode('notFound')); // typeSync + expect(() => file.readAsBytesSync(), throwsA(isA())); + + // readAsBytesSync path is directory throws + setMockSyncResponse(utf8.encode('directory')); // typeSync + expect(() => file.readAsBytesSync(), throwsA(isA())); + + // renameSync path notFound throws + setMockSyncResponse(utf8.encode('notFound')); // typeSync self + expect(() => file.renameSync('/new'), throwsA(isA())); + + // renameSync parent not directory throws + setMockSyncResponse(utf8.encode('file')); // typeSync self + setMockSyncResponse(utf8.encode('file')); // typeSync parent + expect(() => file.renameSync('/parent_file/new'), throwsA(isA())); + + // renameSync successful + setMockSyncResponse(utf8.encode('file')); // typeSync self + setMockSyncResponse(utf8.encode('directory')); // typeSync parent + setMockSyncResponse([]); // rename + final renamedFile = file.renameSync('/new_parent/new_file'); + expect(renamedFile.path, equals('/new_parent/new_file')); + + // deleteSync path notFound throws + setMockSyncResponse(utf8.encode('notFound')); // typeSync self + expect(() => file.deleteSync(), throwsA(isA())); + + // 6. WebLink sync branches + final link = fs.link('/sync_link_cov'); + // createSync already exists throws + setMockSyncResponse(utf8.encode('link')); // existsSync + expect(() => link.createSync('/target'), throwsA(isA())); + + // createSync parent notFound, recursive: false throws + setMockSyncResponse(utf8.encode('notFound')); // existsSync + setMockSyncResponse(utf8.encode('notFound')); // parent typeSync + expect(() => link.createSync('/target'), throwsA(isA())); + + // createSync parent notFound, recursive: true + setMockSyncResponse(utf8.encode('notFound')); // existsSync + setMockSyncResponse(utf8.encode('notFound')); // parent typeSync + setMockSyncResponse(utf8.encode('notFound')); // parent existsSync + setMockSyncResponse(utf8.encode('directory')); // parent-parent typeSync + setMockSyncResponse([]); // parent createDir + setMockSyncResponse([]); // createLink + link.createSync('/target', recursive: true); + + // createSync parent is file throws + setMockSyncResponse(utf8.encode('notFound')); // existsSync + setMockSyncResponse(utf8.encode('file')); // parent typeSync + expect(() => link.createSync('/target'), throwsA(isA())); + + // updateSync path notFound throws + setMockSyncResponse(utf8.encode('notFound')); // typeSync self + expect(() => link.updateSync('/new'), throwsA(isA())); + + // updateSync path is not link throws + setMockSyncResponse(utf8.encode('file')); // typeSync self + expect(() => link.updateSync('/new'), throwsA(isA())); + + // targetSync path notFound throws + setMockSyncResponse(utf8.encode('notFound')); // typeSync self + expect(() => link.targetSync(), throwsA(isA())); + + // targetSync path is not link throws + setMockSyncResponse(utf8.encode('file')); // typeSync self + expect(() => link.targetSync(), throwsA(isA())); + + // renameSync path notFound throws + setMockSyncResponse(utf8.encode('notFound')); // typeSync self + expect(() => link.renameSync('/new'), throwsA(isA())); + + // renameSync parent not directory throws + setMockSyncResponse(utf8.encode('link')); // typeSync self + setMockSyncResponse(utf8.encode('file')); // typeSync parent + expect(() => link.renameSync('/parent_file/new'), throwsA(isA())); + + // renameSync successful + setMockSyncResponse(utf8.encode('link')); // typeSync self + setMockSyncResponse(utf8.encode('directory')); // typeSync parent + setMockSyncResponse([]); // rename + final renamedLink = link.renameSync('/new_parent/new_link'); + expect(renamedLink.path, equals('/new_parent/new_link')); + + // deleteSync path notFound throws + setMockSyncResponse(utf8.encode('notFound')); // typeSync self + expect(() => link.deleteSync(), throwsA(isA())); + }); + }); + }); } @JS('eval') @@ -591,3 +1079,64 @@ void setMockIDBPutShouldFail(bool value) { void setMockIDBIndexGetShouldFail(bool value) { jsEval('window.mockIDBIndexGetShouldFail = $value;'); } + +void setupSyncMockJS() { + jsEval(''' + window.originalImportScripts = window.importScripts; + window.importScripts = function() {}; + window.originalSharedArrayBuffer = window.SharedArrayBuffer; + window.SharedArrayBuffer = function() {}; + window.isVFSSyncWorkerInitialized = true; + window.initVFSSyncWorker = function() {}; + + window.lastSyncCmd = null; + window.lastSyncPayload = null; + window.mockSyncResponseArray = []; + window.syncCmdsHistory = []; + + window.sendVFSSyncRequest = function(cmd, requestBytes) { + window.lastSyncCmd = cmd; + window.syncCmdsHistory.push(cmd); + window.lastSyncPayload = Array.from(requestBytes); + const resp = window.mockSyncResponseArray.shift() || new Uint8Array(0); + return resp; + }; + '''); +} + +void clearSyncMockJS() { + jsEval(''' + window.importScripts = window.originalImportScripts; + window.SharedArrayBuffer = window.originalSharedArrayBuffer; + delete window.sendVFSSyncRequest; + delete window.lastSyncCmd; + delete window.lastSyncPayload; + delete window.mockSyncResponseArray; + delete window.syncCmdsHistory; + delete window.isVFSSyncWorkerInitialized; + delete window.initVFSSyncWorker; + '''); +} + +List getSyncCmdsHistory() { + final jsVal = jsEval('JSON.stringify(window.syncCmdsHistory || [])'); + if (jsVal == null) return []; + return List.from(json.decode((jsVal as JSString).toDart)); +} + +int? getLastSyncCmd() { + final jsVal = jsEval('window.lastSyncCmd'); + if (jsVal == null) return null; + return (jsVal as JSNumber).toDartInt; +} + +List? getLastSyncPayload() { + final jsVal = jsEval('window.lastSyncPayload ? JSON.stringify(window.lastSyncPayload) : null'); + if (jsVal == null) return null; + return List.from(json.decode((jsVal as JSString).toDart)); +} + +void setMockSyncResponse(List bytes) { + final jsonBytes = json.encode(bytes); + jsEval('window.mockSyncResponseArray.push(new Uint8Array($jsonBytes));'); +}