chore(web_file_system): sync changes from open_xml including idb_block_store, getAsBlob, and tests

This commit is contained in:
2026-06-10 21:35:21 -07:00
parent afb6302f0d
commit a627abc813
17 changed files with 1037 additions and 1923 deletions
@@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:web/web.dart' as web;
import 'package:uuid/uuid.dart';
class IdbBlockStore {
static const String _dbName = 'WebFileSystemBlobs';
static const int _version = 1;
static const String _storeName = 'blobs';
web.IDBDatabase? _db;
final Completer<void> _initCompleter = Completer<void>();
final Uuid _uuid = Uuid();
Future<void> _ensureReady() async {
if (_db != null) return;
if (_initCompleter.isCompleted) return _initCompleter.future;
final request = web.window.indexedDB.open(_dbName, _version);
request.onupgradeneeded = (web.IDBVersionChangeEvent event) {
final db =
(event.target as web.IDBOpenDBRequest).result as web.IDBDatabase;
if (!db.objectStoreNames.contains(_storeName)) {
db.createObjectStore(_storeName);
}
}.toJS;
final completer = Completer<void>();
request.onsuccess = (web.Event event) {
_db = (event.target as web.IDBOpenDBRequest).result as web.IDBDatabase;
if (!_initCompleter.isCompleted) completer.complete();
}.toJS;
request.onerror = (web.Event event) {
if (!_initCompleter.isCompleted) {
completer.completeError(Exception('Failed to open Blob IDB'));
}
}.toJS;
return completer.future;
}
Future<String> writeBlob(Stream<List<int>> stream) async {
await _ensureReady();
final blockId = _uuid.v4();
// Read all bytes into memory to store in IDB (IDB requires complete blob/buffer usually)
// For streams, we must materialize them.
final chunks = await stream.toList();
final allBytes = chunks.expand((x) => x).toList();
final uint8Array = Uint8List.fromList(allBytes).toJS;
final transaction = _db!.transaction(
_storeName.toJS,
'readwrite',
);
final store = transaction.objectStore(_storeName);
// Using put(value, key) since we didn't specify keyPath/autoIncrement
final request = store.put(uint8Array, blockId.toJS);
await _requestToFuture(request);
return blockId;
}
Stream<List<int>> readBlob(String blockId) async* {
await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS,
'readonly',
);
final store = transaction.objectStore(_storeName);
final request = store.get(blockId.toJS);
final result = await _requestToFuture(request);
if (result == null) throw Exception('Blob $blockId not found');
// result is JSUint8Array or ArrayBuffer
final uint8Array = result as JSUint8Array;
yield uint8Array.toDart;
}
Future<void> deleteBlob(String blockId) async {
await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS,
'readwrite',
);
final store = transaction.objectStore(_storeName);
final request = store.delete(blockId.toJS);
await _requestToFuture(request);
}
Future<web.Blob> getBlob(String blockId) async {
await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS,
'readonly',
);
final store = transaction.objectStore(_storeName);
final request = store.get(blockId.toJS);
final result = await _requestToFuture(request);
if (result == null) throw Exception('Blob $blockId not found');
final uint8Array = result as JSUint8Array;
return web.Blob([uint8Array].toJS);
}
Future<dynamic> _requestToFuture(web.IDBRequest request) {
final completer = Completer<dynamic>();
request.onsuccess = (web.Event e) {
completer.complete((e.target as web.IDBRequest).result);
}.toJS;
request.onerror = (web.Event e) {
completer.completeError(Exception('IDB Blob Error'));
}.toJS;
return completer.future;
}
}
@@ -1,29 +1,9 @@
import 'dart:async';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:web/web.dart' as web;
extension type InodeJS._(JSObject _) implements JSObject {
external String get id;
external set id(String value);
external String get parentId;
external set parentId(String value);
external String get name;
external set name(String value);
external int get nodeType;
external set nodeType(int value);
external String? get blobId;
external set blobId(String? value);
external int get size;
external set size(int value);
external int get modified;
external set modified(int value);
factory InodeJS({
required String id,
required String parentId,
@@ -32,16 +12,18 @@ extension type InodeJS._(JSObject _) implements JSObject {
String? blobId,
int size = 0,
required int modified,
int storageType = 0,
}) {
final obj = JSObject() as InodeJS;
obj.id = id;
obj.parentId = parentId;
obj.name = name;
obj.nodeType = nodeType;
obj.blobId = blobId;
obj.size = size;
obj.modified = modified;
return obj;
final obj = JSObject();
obj.setProperty('id'.toJS, id.toJS);
obj.setProperty('parentId'.toJS, parentId.toJS);
obj.setProperty('name'.toJS, name.toJS);
obj.setProperty('nodeType'.toJS, nodeType.toJS);
if (blobId != null) obj.setProperty('blobId'.toJS, blobId.toJS);
obj.setProperty('size'.toJS, size.toJS);
obj.setProperty('modified'.toJS, modified.toJS);
obj.setProperty('storageType'.toJS, storageType.toJS);
return obj as InodeJS;
}
}
@@ -53,6 +35,7 @@ class Inode {
final String? blobId;
final int size;
final int modified;
final int storageType; // 0: OPFS, 1: IDB
Inode({
required this.id,
@@ -62,6 +45,7 @@ class Inode {
this.blobId,
this.size = 0,
required this.modified,
this.storageType = 0,
});
InodeJS toJS() {
@@ -73,18 +57,30 @@ class Inode {
blobId: blobId,
size: size,
modified: modified,
storageType: storageType,
);
}
static Inode fromJS(InodeJS js) {
final obj = js as JSObject;
final id = obj.getProperty('id'.toJS);
final parentId = obj.getProperty('parentId'.toJS);
final name = obj.getProperty('name'.toJS);
final nodeType = obj.getProperty('nodeType'.toJS);
final blobId = obj.getProperty('blobId'.toJS);
final size = obj.getProperty('size'.toJS);
final modified = obj.getProperty('modified'.toJS);
final storageType = obj.getProperty('storageType'.toJS);
return Inode(
id: js.id,
parentId: js.parentId,
name: js.name,
nodeType: js.nodeType,
blobId: js.blobId,
size: js.size,
modified: js.modified,
id: id != null && !id.isUndefinedOrNull ? (id as JSString).toDart : '',
parentId: parentId != null && !parentId.isUndefinedOrNull ? (parentId as JSString).toDart : '',
name: name != null && !name.isUndefinedOrNull ? (name as JSString).toDart : '',
nodeType: nodeType != null && !nodeType.isUndefinedOrNull ? (nodeType as JSNumber).toDartInt : 0,
blobId: blobId != null && !blobId.isUndefinedOrNull ? (blobId as JSString).toDart : null,
size: size != null && !size.isUndefinedOrNull ? (size as JSNumber).toDartInt : 0,
modified: modified != null && !modified.isUndefinedOrNull ? (modified as JSNumber).toDartInt : 0,
storageType: storageType != null && !storageType.isUndefinedOrNull ? (storageType as JSNumber).toDartInt : 0,
);
}
}
@@ -95,16 +91,13 @@ class IdbInodeService {
static const String _storeName = 'inodes';
web.IDBDatabase? _db;
Future<void>? _initFuture;
final Completer<void> _initCompleter = Completer<void>();
static const String rootId = '00000000-0000-0000-0000-000000000000';
Future<void> _ensureReady() {
if (_db != null) return Future.value();
return _initFuture ??= _init();
}
Future<void> _ensureReady() async {
if (_db != null) return;
if (_initCompleter.isCompleted) return _initCompleter.future;
Future<void> _init() async {
final completer = Completer<void>();
final request = web.window.indexedDB.open(_dbName, _version);
request.onupgradeneeded = (web.IDBVersionChangeEvent event) {
@@ -116,23 +109,35 @@ class IdbInodeService {
web.IDBObjectStoreParameters(keyPath: 'id'.toJS),
);
store.createIndex(
'parentId', 'parentId'.toJS, web.IDBIndexParameters(unique: false));
store.createIndex('parent_name', ['parentId'.toJS, 'name'.toJS].toJS,
web.IDBIndexParameters(unique: true));
'parentId',
'parentId'.toJS,
web.IDBIndexParameters(unique: false),
);
store.createIndex(
'parent_name',
['parentId'.toJS, 'name'.toJS].toJS,
web.IDBIndexParameters(unique: true),
);
}
}.toJS;
final completer = Completer<void>();
request.onsuccess = (web.Event event) {
_db = (event.target as web.IDBOpenDBRequest).result as web.IDBDatabase;
_ensureRootExists().then((_) {
completer.complete();
}).catchError((e) {
completer.completeError(e);
});
_ensureRootExists()
.then((_) {
if (!_initCompleter.isCompleted) completer.complete();
})
.catchError((e) {
if (!_initCompleter.isCompleted) completer.completeError(e);
});
}.toJS;
request.onerror = (web.Event event) {
completer.completeError(Exception('Failed to open IDB'));
if (!_initCompleter.isCompleted) {
completer.completeError(Exception('Failed to open IDB'));
}
}.toJS;
return completer.future;
@@ -142,20 +147,24 @@ class IdbInodeService {
try {
await getInode(rootId);
} catch (_) {
await createInode(Inode(
id: rootId,
parentId: 'null',
name: '',
nodeType: 1,
modified: DateTime.now().millisecondsSinceEpoch,
));
await createInode(
Inode(
id: rootId,
parentId: 'null',
name: '',
nodeType: 1,
modified: DateTime.now().millisecondsSinceEpoch,
),
);
}
}
Future<void> createInode(Inode inode) async {
if (_db == null) await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS, 'readwrite'.toJS as web.IDBTransactionMode);
_storeName.toJS,
'readwrite',
);
final store = transaction.objectStore(_storeName);
final request = store.put(inode.toJS());
await _requestToFuture(request);
@@ -168,7 +177,9 @@ class IdbInodeService {
Future<void> deleteInode(String id) async {
await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS, 'readwrite'.toJS as web.IDBTransactionMode);
_storeName.toJS,
'readwrite',
);
final store = transaction.objectStore(_storeName);
final request = store.delete(id.toJS);
await _requestToFuture(request);
@@ -178,7 +189,9 @@ class IdbInodeService {
if (_db == null) await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS, 'readonly'.toJS as web.IDBTransactionMode);
_storeName.toJS,
'readonly',
);
final store = transaction.objectStore(_storeName);
final request = store.get(id.toJS);
final result = await _requestToFuture(request);
@@ -190,7 +203,9 @@ class IdbInodeService {
Future<Inode?> getChild(String parentId, String name) async {
await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS, 'readonly'.toJS as web.IDBTransactionMode);
_storeName.toJS,
'readonly',
);
final store = transaction.objectStore(_storeName);
final index = store.index('parent_name');
final key = JSArray();
@@ -211,7 +226,9 @@ class IdbInodeService {
Future<List<Inode>> listChildren(String parentId) async {
await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS, 'readonly'.toJS as web.IDBTransactionMode);
_storeName.toJS,
'readonly',
);
final store = transaction.objectStore(_storeName);
final index = store.index('parentId');
final request = index.getAll(parentId.toJS);
@@ -12,10 +12,7 @@ class OpfsBlockStore {
Future<void> _ensureReady() async {
if (_blocksDir != null) return;
final web.StorageManager? storage = web.window.navigator.storage;
if (storage == null) {
throw UnsupportedError('StorageManager not supported');
}
final web.StorageManager storage = web.window.navigator.storage;
final root = await storage.getDirectory().toDart;
_blocksDir = await root
@@ -26,24 +23,19 @@ class OpfsBlockStore {
.toDart;
}
Future<(String, int)> writeBlob(Stream<List<int>> stream) async {
Future<String> writeBlob(Stream<List<int>> stream) async {
await _ensureReady();
final blockId = _uuid.v4();
final fileHandle = await _blocksDir!
.getFileHandle(
blockId,
web.FileSystemGetFileOptions(create: true),
)
.getFileHandle(blockId, web.FileSystemGetFileOptions(create: true))
.toDart;
final writable = await fileHandle.createWritable().toDart;
int totalBytes = 0;
try {
await for (final chunk in stream) {
final uint8 = Uint8List.fromList(chunk);
totalBytes += uint8.length;
await writable.write(uint8.toJS).toDart;
}
await writable.close().toDart;
@@ -52,20 +44,17 @@ class OpfsBlockStore {
await writable.abort().toDart;
await _blocksDir!.removeEntry(blockId).toDart;
} catch (_) {}
print('OpfsBlockStore: writeBlob failed for $blockId: $e');
rethrow;
}
return (blockId, totalBytes);
return blockId;
}
Stream<List<int>> readBlob(String blockId) async* {
await _ensureReady();
try {
final fileHandle = await _blocksDir!
.getFileHandle(
blockId,
)
.toDart;
final fileHandle = await _blocksDir!.getFileHandle(blockId).toDart;
final file = await fileHandle.getFile().toDart;
final web.Blob blob = file;
@@ -80,6 +69,7 @@ class OpfsBlockStore {
yield chunk.toDart;
}
} catch (e) {
print('OpfsBlockStore: readBlob failed for $blockId: $e');
rethrow;
}
}
@@ -92,4 +82,11 @@ class OpfsBlockStore {
// Ignore if not found
}
}
Future<web.Blob> getBlob(String blockId) async {
await _ensureReady();
final fileHandle = await _blocksDir!.getFileHandle(blockId).toDart;
final file = await fileHandle.getFile().toDart;
return file;
}
}
@@ -1,234 +0,0 @@
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;
}
}
@@ -1,11 +1,8 @@
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 '../backend/idb_inode_service.dart';
import '../web_file_system.dart';
import 'web_file.dart';
import 'web_link.dart';
class WebDirectory extends FileSystemEntity implements Directory {
final WebFileSystem _fs;
@@ -36,13 +33,19 @@ class WebDirectory extends FileSystemEntity implements Directory {
// We assume parent must exist if not recursive.
final parentInode = await _fs.resolvepath(parentPath); // throws if missing
await _fs.idb.createInode(Inode(
id: _fs.uuid.v4(),
parentId: parentInode.id,
name: name,
nodeType: 1, // Directory
modified: DateTime.now().millisecondsSinceEpoch,
));
try {
await _fs.idb.createInode(
Inode(
id: _fs.uuid.v4(),
parentId: parentInode.id,
name: name,
nodeType: 1, // Directory
modified: DateTime.now().millisecondsSinceEpoch,
),
);
} catch (_) {
if (!await exists()) rethrow;
}
return this;
}
@@ -53,37 +56,24 @@ class WebDirectory extends FileSystemEntity implements Directory {
await _createRecursiveSafe(_fs.path.dirname(p));
final parentVal = await _fs.resolvepath(_fs.path.dirname(p));
await _fs.idb.createInode(Inode(
id: _fs.uuid.v4(),
parentId: parentVal.id,
name: _fs.path.basename(p),
nodeType: 1,
modified: DateTime.now().millisecondsSinceEpoch));
try {
await _fs.idb.createInode(
Inode(
id: _fs.uuid.v4(),
parentId: parentVal.id,
name: _fs.path.basename(p),
nodeType: 1,
modified: DateTime.now().millisecondsSinceEpoch,
),
);
} catch (_) {
if (await _fs.type(p) == FileSystemEntityType.notFound) rethrow;
}
}
@override
void createSync({bool recursive = false}) {
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));
throw UnsupportedError('Sync create not supported');
}
@override
@@ -92,8 +82,15 @@ class WebDirectory extends FileSystemEntity implements Directory {
final tempDir = _fs.path.join(path, name);
// Ensure path exists
if (!await exists()) {
throw FileSystemException(
'Directory does not exist', path, const OSError('ENOENT', 2));
if (path == '/tmp') {
await create();
} else {
throw FileSystemException(
'Directory does not exist',
path,
const OSError('ENOENT', 2),
);
}
}
final dir = WebDirectory(_fs, tempDir);
await dir.create();
@@ -102,15 +99,7 @@ class WebDirectory extends FileSystemEntity implements Directory {
@override
Directory createTempSync([String? prefix]) {
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;
throw UnsupportedError('Sync not supported');
}
@override
@@ -120,7 +109,10 @@ class WebDirectory extends FileSystemEntity implements Directory {
final children = await _fs.idb.listChildren(inode.id);
if (children.isNotEmpty && !recursive) {
throw FileSystemException(
'Directory not empty', path, const OSError('ENOTEMPTY', 39));
'Directory not empty',
path,
const OSError('ENOTEMPTY', 39),
);
}
if (recursive) {
@@ -139,34 +131,8 @@ class WebDirectory extends FileSystemEntity implements Directory {
}
@override
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));
}
void deleteSync({bool recursive = false}) =>
throw UnsupportedError('Sync not supported');
@override
Future<bool> exists() async {
@@ -179,16 +145,19 @@ class WebDirectory extends FileSystemEntity implements Directory {
}
@override
bool existsSync() {
return _fs.typeSync(path, followLinks: false) == FileSystemEntityType.directory;
}
bool existsSync() => throw UnsupportedError('Sync not supported');
@override
Stream<FileSystemEntity> list(
{bool recursive = false, bool followLinks = true}) async* {
Stream<FileSystemEntity> list({
bool recursive = false,
bool followLinks = true,
}) async* {
if (!await exists()) {
throw FileSystemException(
'Directory not found', path, const OSError('ENOENT', 2));
'Directory not found',
path,
const OSError('ENOENT', 2),
);
}
final inode = await _fs.resolvepath(path);
@@ -202,26 +171,6 @@ class WebDirectory extends FileSystemEntity implements Directory {
if (recursive) {
yield* dir.list(recursive: true, followLinks: followLinks);
}
} else if (child.nodeType == 2) {
if (!followLinks) {
yield WebLink(_fs, childPath);
} else {
try {
final resolved =
await _fs.resolvepath(childPath, followLinks: true);
if (resolved.nodeType == 1) {
final dir = WebDirectory(_fs, childPath);
yield dir;
if (recursive) {
yield* dir.list(recursive: true, followLinks: true);
}
} else {
yield WebFile(_fs, childPath);
}
} catch (_) {
yield WebLink(_fs, childPath);
}
}
} else {
yield WebFile(_fs, childPath);
}
@@ -229,54 +178,11 @@ class WebDirectory extends FileSystemEntity implements Directory {
}
@override
List<FileSystemEntity> listSync(
{bool recursive = false, bool followLinks = true}) {
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<dynamic>;
final List<FileSystemEntity> results = [];
for (final item in listJson) {
final itemMap = item as Map<String, dynamic>;
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;
List<FileSystemEntity> listSync({
bool recursive = false,
bool followLinks = true,
}) {
throw UnsupportedError('Sync list not supported');
}
@override
@@ -288,46 +194,22 @@ class WebDirectory extends FileSystemEntity implements Directory {
final newParentInode = await _fs.resolvepath(newParentDir);
final updated = Inode(
id: inode.id,
parentId: newParentInode.id,
name: newName,
nodeType: inode.nodeType,
blobId: inode.blobId,
size: inode.size,
modified: DateTime.now().millisecondsSinceEpoch);
id: inode.id,
parentId: newParentInode.id,
name: newName,
nodeType: inode.nodeType,
blobId: inode.blobId,
size: inode.size,
modified: DateTime.now().millisecondsSinceEpoch,
);
await _fs.idb.updateInode(updated);
return WebDirectory(_fs, newPath);
}
@override
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);
}
Directory renameSync(String newPath) =>
throw UnsupportedError('Sync not supported');
@override
String get basename => _fs.path.basename(path);
@@ -348,17 +230,20 @@ class WebDirectory extends FileSystemEntity implements Directory {
Future<FileStat> stat() => _fs.stat(path);
@override
FileStat statSync() => _fs.statSync(path);
FileStat statSync() => throw UnsupportedError('Sync not supported');
@override
Future<String> resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path);
Future<String> resolveSymbolicLinks() async => path;
@override
String resolveSymbolicLinksSync() => _fs.resolveSymbolicLinksSync(path);
String resolveSymbolicLinksSync() =>
throw UnsupportedError('Sync not supported');
@override
Stream<FileSystemEvent> watch(
{int events = FileSystemEvent.all, bool recursive = false}) {
Stream<FileSystemEvent> watch({
int events = FileSystemEvent.all,
bool recursive = false,
}) {
return const Stream.empty();
}
@@ -2,7 +2,7 @@ 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 '../backend/idb_inode_service.dart';
import '../web_file_system.dart';
class WebFile extends FileSystemEntity implements File {
@@ -52,65 +52,28 @@ class WebFile extends FileSystemEntity implements File {
}
@override
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([]);
}
void createSync({bool recursive = false, bool exclusive = false}) =>
throw UnsupportedError('Sync not supported');
@override
Future<File> copy(String newPath) async {
final inode = await _fs.resolvepath(path);
final newParent = await _fs.resolvepath(_fs.path.dirname(newPath));
// Deep copy to ensure independence and correct storage type
await _fs.idb.createInode(
Inode(
id: _fs.uuid.v4(),
parentId: newParent.id,
name: _fs.path.basename(newPath),
nodeType: 0,
blobId: inode.blobId,
size: inode.size,
modified: DateTime.now().millisecondsSinceEpoch,
),
);
final newFile = WebFile(_fs, newPath);
// Use writeAsBytes to handle creation and storage logic
// We need to consume the stream to a list for now as writeAsBytes takes List<int>
// Or we could use a stream API if available, but writeAsBytes expects List<int>.
// Given these are images/slides, memory is likely okay.
// To be safer with memory, we should really stream it, but writeAsBytes takes List<int>.
// Let's use readAsBytes() helper.
final bytes = await readAsBytes();
await newFile.writeAsBytes(bytes);
return WebFile(_fs, newPath);
return newFile;
}
@override
File copySync(String newPath) {
final bytes = readAsBytesSync();
_fs.file(newPath).writeAsBytesSync(bytes);
return WebFile(_fs, newPath);
}
File copySync(String newPath) => throw UnsupportedError('Sync not supported');
@override
Future<int> length() async {
@@ -119,7 +82,7 @@ class WebFile extends FileSystemEntity implements File {
}
@override
int lengthSync() => statSync().size;
int lengthSync() => throw UnsupportedError('Sync not supported');
@override
Future<DateTime> lastModified() async {
@@ -128,7 +91,7 @@ class WebFile extends FileSystemEntity implements File {
}
@override
DateTime lastModifiedSync() => statSync().modified;
DateTime lastModifiedSync() => throw UnsupportedError('Sync not supported');
@override
Future<DateTime> lastAccessed() async {
@@ -136,7 +99,7 @@ class WebFile extends FileSystemEntity implements File {
}
@override
DateTime lastAccessedSync() => statSync().accessed;
DateTime lastAccessedSync() => throw UnsupportedError('Sync not supported');
@override
Future<dynamic> setLastAccessed(DateTime time) async {}
@@ -183,7 +146,24 @@ class WebFile extends FileSystemEntity implements File {
bool flush = false,
}) async {
final stream = Stream.value(bytes);
final (newBlobId, _) = await _fs.opfs.writeBlob(stream);
String newBlobId;
int usedStorageType = 0; // 0 = OPFS, 1 = IDB
try {
// Try OPFS first
newBlobId = await _fs.opfs.writeBlob(stream);
} catch (e) {
// Fallback to IDB on ANY error (TypeError, NotFoundError, etc)
try {
newBlobId = await _fs.idbStore.writeBlob(Stream.value(bytes));
usedStorageType = 1;
} catch (e2) {
throw FileSystemException(
'Write failed on both OPFS ($e) and IDB: $e2',
path,
);
}
}
Inode inode;
try {
@@ -191,15 +171,8 @@ class WebFile extends FileSystemEntity implements File {
if (mode == FileMode.append) {
throw UnsupportedError('Append not yet optimized');
}
} on FileSystemException catch (_) {
final parentPath = _fs.path.dirname(path);
if (await _fs.type(parentPath) != FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot open file, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
await create(recursive: false);
} catch (_) {
await create(recursive: true);
inode = await _fs.resolvepath(path);
}
@@ -212,6 +185,7 @@ class WebFile extends FileSystemEntity implements File {
blobId: newBlobId,
size: bytes.length,
modified: DateTime.now().millisecondsSinceEpoch,
storageType: usedStorageType,
),
);
@@ -224,27 +198,7 @@ class WebFile extends FileSystemEntity implements File {
FileMode mode = FileMode.write,
bool flush = false,
}) {
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);
throw UnsupportedError('Sync not supported');
}
@override
@@ -264,7 +218,7 @@ class WebFile extends FileSystemEntity implements File {
Encoding encoding = utf8,
bool flush = false,
}) {
writeAsBytesSync(encoding.encode(contents), mode: mode, flush: flush);
throw UnsupportedError('Sync not supported');
}
@override
@@ -272,7 +226,37 @@ class WebFile extends FileSystemEntity implements File {
final inode = await _fs.resolvepath(path);
if (inode.blobId == null) return;
yield* _fs.opfs.readBlob(inode.blobId!);
if (inode.storageType == 1) {
try {
yield* _fs.idbStore.readBlob(inode.blobId!);
} catch (e) {
// Fallback to OPFS if IDB fails (maybe metadata is wrong)
print(
'Using fallback to OPFS for reading ${inode.name} (blob: ${inode.blobId}). Error: $e',
);
try {
yield* _fs.opfs.readBlob(inode.blobId!);
} catch (e2) {
print('OPFS fallback ALSO failed for ${inode.name}: $e2');
rethrow;
}
}
} else {
try {
yield* _fs.opfs.readBlob(inode.blobId!);
} catch (e) {
// Fallback to IDB if OPFS fails
print(
'Using fallback to IDB for reading ${inode.name} (blob: ${inode.blobId}). Error: $e',
);
try {
yield* _fs.idbStore.readBlob(inode.blobId!);
} catch (e2) {
print('IDB fallback ALSO failed for ${inode.name}: $e2');
rethrow;
}
}
}
}
@override
@@ -281,9 +265,16 @@ class WebFile extends FileSystemEntity implements File {
// Start background write but keep future to await in close()
final writeFuture = _handleWrite(controller.stream, encoding, mode);
writeFuture.catchError((Object _) {});
return _WebIOSink(controller, writeFuture, encoding);
final sink = _WebIOSink(
controller,
encoding,
onDone: () async {
await writeFuture;
},
);
return sink;
}
Future<void> _handleWrite(
@@ -292,22 +283,23 @@ class WebFile extends FileSystemEntity implements File {
FileMode mode,
) async {
try {
final parentPath = _fs.path.dirname(path);
if (await _fs.type(parentPath) != FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot open file, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
String newId;
int usedStorageType = 0; // 0 = OPFS, 1 = IDB
final (newId, size) = await _fs.opfs.writeBlob(stream);
try {
newId = await _fs.opfs.writeBlob(stream);
} catch (e) {
// Fallback to IDB on ANY error
newId = await _fs.idbStore.writeBlob(stream);
usedStorageType = 1;
}
Inode inode;
try {
inode = await _fs.resolvepath(path);
} catch (_) {
// Create if missing
await create(recursive: false);
await create(recursive: true);
inode = await _fs.resolvepath(path);
}
@@ -318,8 +310,10 @@ class WebFile extends FileSystemEntity implements File {
name: inode.name,
nodeType: 0,
blobId: newId,
size: size,
size:
0, // TODO: Size not returned by OPFS yet, so 0 for streamed content
modified: DateTime.now().millisecondsSinceEpoch,
storageType: usedStorageType,
),
);
} catch (e) {
@@ -334,22 +328,7 @@ class WebFile extends FileSystemEntity implements File {
}
@override
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));
}
Uint8List readAsBytesSync() => throw UnsupportedError('Sync not supported');
@override
Future<String> readAsString({Encoding encoding = utf8}) async {
@@ -358,21 +337,18 @@ class WebFile extends FileSystemEntity implements File {
}
@override
String readAsStringSync({Encoding encoding = utf8}) {
return encoding.decode(readAsBytesSync());
}
String readAsStringSync({Encoding encoding = utf8}) =>
throw UnsupportedError('Sync not supported');
@override
Future<List<String>> readAsLines({Encoding encoding = utf8}) async {
final str = await readAsString(encoding: encoding);
return const LineSplitter().convert(str);
return str.split('\n');
}
@override
List<String> readAsLinesSync({Encoding encoding = utf8}) {
final str = readAsStringSync(encoding: encoding);
return const LineSplitter().convert(str);
}
List<String> readAsLinesSync({Encoding encoding = utf8}) =>
throw UnsupportedError('Sync not supported');
@override
Future<bool> exists() async {
@@ -385,9 +361,7 @@ class WebFile extends FileSystemEntity implements File {
}
@override
bool existsSync() {
return _fs.typeSync(path, followLinks: false) == FileSystemEntityType.file;
}
bool existsSync() => throw UnsupportedError('Sync not supported');
@override
Future<File> rename(String newPath) async {
@@ -411,58 +385,32 @@ class WebFile extends FileSystemEntity implements File {
}
@override
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);
}
File renameSync(String newPath) =>
throw UnsupportedError('Sync not supported');
@override
Future<FileSystemEntity> delete({bool recursive = false}) async {
final inode = await _fs.resolvepath(path);
if (inode.blobId != null) {
if (inode.storageType == 1) {
await _fs.idbStore.deleteBlob(inode.blobId!);
} else {
await _fs.opfs.deleteBlob(inode.blobId!);
}
}
await _fs.idb.deleteInode(inode.id);
return this;
}
@override
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));
}
void deleteSync({bool recursive = false}) =>
throw UnsupportedError('Sync not supported');
@override
Future<FileStat> stat() => _fs.stat(path);
@override
FileStat statSync() => _fs.statSync(path);
FileStat statSync() => throw UnsupportedError('Sync not supported');
@override
Uri get uri => Uri.parse(path);
@@ -478,10 +426,11 @@ class WebFile extends FileSystemEntity implements File {
File get absolute => WebFile(_fs, _fs.path.absolute(path));
@override
Future<String> resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path);
Future<String> resolveSymbolicLinks() async => path;
@override
String resolveSymbolicLinksSync() => _fs.resolveSymbolicLinksSync(path);
String resolveSymbolicLinksSync() =>
throw UnsupportedError('Sync not supported');
@override
Stream<FileSystemEvent> watch({
@@ -494,10 +443,10 @@ class WebFile extends FileSystemEntity implements File {
class _WebIOSink implements IOSink {
final StreamController<List<int>> _controller;
final Future<void> _writeFuture;
final Future<void> Function()? onDone;
Encoding _encoding;
_WebIOSink(this._controller, this._writeFuture, this._encoding);
_WebIOSink(this._controller, this._encoding, {this.onDone});
@override
Encoding get encoding => _encoding;
@@ -507,35 +456,27 @@ class _WebIOSink implements IOSink {
@override
void add(List<int> data) {
if (_controller.isClosed) return;
_controller.add(data);
}
@override
void addError(Object error, [StackTrace? stackTrace]) {
if (_controller.isClosed) return;
_controller.addError(error, stackTrace);
}
@override
Future addStream(Stream<List<int>> stream) {
return Future.any([
_controller.addStream(stream),
_writeFuture,
]);
return _controller.addStream(stream);
}
@override
Future close() async {
await _controller.close();
await _writeFuture;
if (onDone != null) await onDone!();
}
@override
Future get done => Future.any([
_controller.done,
_writeFuture,
]);
Future get done => _controller.done;
@override
Future flush() async {}
@@ -1,8 +1,7 @@
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 '../backend/idb_inode_service.dart';
import '../web_file_system.dart';
class WebLink extends FileSystemEntity implements Link {
@@ -36,7 +35,7 @@ class WebLink extends FileSystemEntity implements Link {
// Write target path string to OPFS blob
final stream = Stream.value(utf8.encode(target));
final (blobId, _) = await _fs.opfs.writeBlob(stream);
final blobId = await _fs.opfs.writeBlob(stream);
final parentPath = _fs.path.dirname(path);
final parentInode = await _fs.resolvepath(parentPath);
@@ -58,41 +57,7 @@ class WebLink extends FileSystemEntity implements Link {
@override
void createSync(String target, {bool recursive = false}) {
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);
throw UnsupportedError('Sync not supported');
}
@override
@@ -101,7 +66,7 @@ class WebLink extends FileSystemEntity implements Link {
// Write new blob
final stream = Stream.value(utf8.encode(target));
final (blobId, _) = await _fs.opfs.writeBlob(stream);
final blobId = await _fs.opfs.writeBlob(stream);
await _fs.idb.updateInode(
Inode(
@@ -120,30 +85,7 @@ class WebLink extends FileSystemEntity implements Link {
@override
void updateSync(String target) {
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);
throw UnsupportedError('Sync not supported');
}
@override
@@ -165,22 +107,7 @@ class WebLink extends FileSystemEntity implements Link {
@override
String targetSync() {
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);
throw UnsupportedError('Sync not supported');
}
@override
@@ -205,33 +132,8 @@ class WebLink extends FileSystemEntity implements Link {
}
@override
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);
}
Link renameSync(String newPath) =>
throw UnsupportedError('Sync not supported');
@override
Future<FileSystemEntity> delete({bool recursive = false}) async {
@@ -241,16 +143,8 @@ class WebLink extends FileSystemEntity implements Link {
}
@override
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));
}
void deleteSync({bool recursive = false}) =>
throw UnsupportedError('Sync not supported');
@override
Future<bool> exists() async {
@@ -263,15 +157,13 @@ class WebLink extends FileSystemEntity implements Link {
}
@override
bool existsSync() {
return _fs.typeSync(path, followLinks: false) == FileSystemEntityType.link;
}
bool existsSync() => throw UnsupportedError('Sync not supported');
@override
Future<FileStat> stat() async => (await _fs.stat(path));
@override
FileStat statSync() => _fs.statSync(path);
FileStat statSync() => throw UnsupportedError('Sync not supported');
@override
Uri get uri => Uri.parse(path);
@@ -292,10 +184,21 @@ class WebLink extends FileSystemEntity implements Link {
Link get absolute => WebLink(_fs, _fs.path.absolute(path));
@override
Future<String> resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path);
Future<String> resolveSymbolicLinks() async {
// If we are a link, return target? No, resolveSymbolicLinks follows all the way to canonical path.
// For now, simpler: resolve path logic.
final targetPath = await target();
// If target is relative, resolve against directory. This gets complex.
// MVP: Return target path raw? No, contract says "path with all symbolic links resolved".
// This requires full traversal logic.
// For MVP just return the path as we stored it if it's absolute, or join if relative.
if (_fs.path.isAbsolute(targetPath)) return targetPath;
return _fs.path.normalize(_fs.path.join(dirname, targetPath));
}
@override
String resolveSymbolicLinksSync() => _fs.resolveSymbolicLinksSync(path);
String resolveSymbolicLinksSync() =>
throw UnsupportedError('Sync not supported');
@override
Stream<FileSystemEvent> watch({
@@ -1,13 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'dart:js_interop';
import 'package:file/file.dart';
import 'package:web/web.dart' as web;
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 'backend/idb_inode_service.dart';
import 'backend/opfs_block_store.dart';
import 'backend/idb_block_store.dart';
import 'entities/web_directory.dart';
import 'entities/web_file.dart';
import 'entities/web_link.dart';
@@ -15,11 +16,13 @@ import 'entities/web_link.dart';
class WebFileSystem extends FileSystem {
final IdbInodeService _idb = IdbInodeService();
final OpfsBlockStore _opfs = OpfsBlockStore();
final IdbBlockStore _idbStore = IdbBlockStore();
final Uuid _uuid = Uuid();
// Public matchers for internal use
IdbInodeService get idb => _idb;
OpfsBlockStore get opfs => _opfs;
IdbBlockStore get idbStore => _idbStore;
Uuid get uuid => _uuid;
WebFileSystem();
@@ -63,43 +66,9 @@ 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}) {
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;
}
throw UnsupportedError('Sync type not supported');
}
// Internal Resolution Logic
@@ -194,6 +163,7 @@ class WebFileSystem extends FileSystem {
return currentInode;
}
@override
String getPath(dynamic path) {
if (path is String) return path;
if (path is FileSystemEntity) return path.path;
@@ -213,34 +183,7 @@ class WebFileSystem extends FileSystem {
@override
FileStat statSync(String path) {
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<String, dynamic>;
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);
}
throw UnsupportedError('Sync stat not supported');
}
FileSystemEntityType _getType(int nodeType) {
@@ -252,15 +195,15 @@ class WebFileSystem extends FileSystem {
@override
bool isFileSync(String path) =>
typeSync(path) == FileSystemEntityType.file;
throw UnsupportedError('Sync isFile not supported');
@override
bool isDirectorySync(String path) =>
typeSync(path) == FileSystemEntityType.directory;
throw UnsupportedError('Sync isDirectory not supported');
@override
bool isLinkSync(String path) =>
typeSync(path, followLinks: false) == FileSystemEntityType.link;
throw UnsupportedError('Sync isLink not supported');
@override
Future<bool> isFile(String path) async =>
@@ -274,6 +217,7 @@ class WebFileSystem extends FileSystem {
Future<bool> isLink(String path) async =>
(await type(path, followLinks: false)) == FileSystemEntityType.link;
@override
bool get isWatchSupported => false;
@override
@@ -281,8 +225,9 @@ class WebFileSystem extends FileSystem {
final s1 = await stat(path1);
final s2 = await stat(path2);
if (s1.type == FileSystemEntityType.notFound ||
s2.type == FileSystemEntityType.notFound)
s2.type == FileSystemEntityType.notFound) {
return false;
}
final i1 = await resolvepath(path1);
final i2 = await resolvepath(path2);
@@ -290,31 +235,20 @@ class WebFileSystem extends FileSystem {
}
@override
bool identicalSync(String path1, String path2) {
try {
final r1 = resolveSymbolicLinksSync(path1);
final r2 = resolveSymbolicLinksSync(path2);
return r1 == r2;
} catch (_) {
return false;
}
}
@override
bool identicalSync(String path1, String path2) =>
throw UnsupportedError('Sync not supported');
Future<String> resolveSymbolicLinks(String pathStr) async {
final inode = await resolvepath(pathStr, followLinks: true);
final List<String> segments = [];
Inode current = inode;
while (current.id != IdbInodeService.rootId) {
segments.add(current.name);
current = await _idb.getInode(current.parentId);
}
if (segments.isEmpty) return '/';
return '/' + segments.reversed.join('/');
}
Future<web.Blob> getAsBlob(String path) async {
final inode = await resolvepath(path);
if (inode.nodeType != 0) throw FileSystemException('Not a file', path);
if (inode.blobId == null) return web.Blob(<JSAny>[].toJS);
String resolveSymbolicLinksSync(String pathStr) {
final respBytes = makeSyncCall(11, utf8.encode(pathStr));
return utf8.decode(respBytes);
if (inode.storageType == 1) {
return _idbStore.getBlob(inode.blobId!);
} else {
return _opfs.getBlob(inode.blobId!);
}
}
}
@@ -1,5 +1,5 @@
/// A high-performance, asynchronous file system for the web.
library web_file_system;
library;
export 'package:file/file.dart';
export 'src/web_file_system.dart';