adding packages

This commit is contained in:
2026-01-15 14:38:46 -08:00
parent ef86e3ab6a
commit 6869cf47e5
5253 changed files with 726695 additions and 34 deletions
@@ -0,0 +1,234 @@
import 'dart:async';
import 'dart:js_interop';
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,
required String name,
required int nodeType,
String? blobId,
int size = 0,
required int modified,
}) {
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;
}
}
class Inode {
final String id;
final String parentId;
final String name;
final int nodeType;
final String? blobId;
final int size;
final int modified;
Inode({
required this.id,
required this.parentId,
required this.name,
required this.nodeType,
this.blobId,
this.size = 0,
required this.modified,
});
InodeJS toJS() {
return InodeJS(
id: id,
parentId: parentId,
name: name,
nodeType: nodeType,
blobId: blobId,
size: size,
modified: modified,
);
}
static Inode fromJS(InodeJS js) {
return Inode(
id: js.id,
parentId: js.parentId,
name: js.name,
nodeType: js.nodeType,
blobId: js.blobId,
size: js.size,
modified: js.modified,
);
}
}
class IdbInodeService {
static const String _dbName = 'WebFileSystemDB';
static const int _version = 1;
static const String _storeName = 'inodes';
web.IDBDatabase? _db;
final Completer<void> _initCompleter = Completer<void>();
static const String rootId = '00000000-0000-0000-0000-000000000000';
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)) {
final store = db.createObjectStore(
_storeName,
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));
}
}.toJS;
final completer = Completer<void>();
request.onsuccess = (web.Event event) {
_db = (event.target as web.IDBOpenDBRequest).result as web.IDBDatabase;
_ensureRootExists().then((_) {
if (!_initCompleter.isCompleted) completer.complete();
}).catchError((e) {
if (!_initCompleter.isCompleted) completer.completeError(e);
});
}.toJS;
request.onerror = (web.Event event) {
if (!_initCompleter.isCompleted)
completer.completeError(Exception('Failed to open IDB'));
}.toJS;
return completer.future;
}
Future<void> _ensureRootExists() async {
try {
await getInode(rootId);
} catch (_) {
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);
final store = transaction.objectStore(_storeName);
final request = store.put(inode.toJS());
await _requestToFuture(request);
}
Future<void> updateInode(Inode inode) async {
await createInode(inode);
}
Future<void> deleteInode(String id) async {
await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS, 'readwrite'.toJS as web.IDBTransactionMode);
final store = transaction.objectStore(_storeName);
final request = store.delete(id.toJS);
await _requestToFuture(request);
}
Future<Inode> getInode(String id) async {
if (_db == null) await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS, 'readonly'.toJS as web.IDBTransactionMode);
final store = transaction.objectStore(_storeName);
final request = store.get(id.toJS);
final result = await _requestToFuture(request);
if (result == null) throw Exception('Inode $id not found');
return Inode.fromJS(result as InodeJS);
}
Future<Inode?> getChild(String parentId, String name) async {
await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS, 'readonly'.toJS as web.IDBTransactionMode);
final store = transaction.objectStore(_storeName);
final index = store.index('parent_name');
final key = JSArray();
key.add(parentId.toJS);
key.add(name.toJS);
final request = index.get(key);
try {
final result = await _requestToFuture(request);
if (result == null) return null;
return Inode.fromJS(result as InodeJS);
} catch (e) {
return null;
}
}
Future<List<Inode>> listChildren(String parentId) async {
await _ensureReady();
final transaction = _db!.transaction(
_storeName.toJS, 'readonly'.toJS as web.IDBTransactionMode);
final store = transaction.objectStore(_storeName);
final index = store.index('parentId');
final request = index.getAll(parentId.toJS);
final result = await _requestToFuture(request);
final list = (result as JSArray).toDart;
return list.map((item) => Inode.fromJS(item as InodeJS)).toList();
}
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 Error'));
}.toJS;
return completer.future;
}
}
@@ -0,0 +1,93 @@
import 'dart:async';
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:web/web.dart' as web;
import 'package:uuid/uuid.dart';
class OpfsBlockStore {
static const String _blocksDirName = '.blocks';
web.FileSystemDirectoryHandle? _blocksDir;
final Uuid _uuid = Uuid();
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 root = await storage.getDirectory().toDart;
_blocksDir = await root
.getDirectoryHandle(
_blocksDirName,
web.FileSystemGetDirectoryOptions(create: true),
)
.toDart;
}
Future<String> writeBlob(Stream<List<int>> stream) async {
await _ensureReady();
final blockId = _uuid.v4();
final fileHandle = await _blocksDir!
.getFileHandle(
blockId,
web.FileSystemGetFileOptions(create: true),
)
.toDart;
final writable = await fileHandle.createWritable().toDart;
try {
await for (final chunk in stream) {
final uint8 = Uint8List.fromList(chunk);
await writable.write(uint8.toJS).toDart;
}
await writable.close().toDart;
} catch (e) {
try {
await writable.abort().toDart;
await _blocksDir!.removeEntry(blockId).toDart;
} catch (_) {}
rethrow;
}
return blockId;
}
Stream<List<int>> readBlob(String blockId) async* {
await _ensureReady();
try {
final fileHandle = await _blocksDir!
.getFileHandle(
blockId,
)
.toDart;
final file = await fileHandle.getFile().toDart;
final web.Blob blob = file;
final reader =
blob.stream().getReader() as web.ReadableStreamDefaultReader;
while (true) {
final result = await reader.read().toDart;
if (result.done) break;
// Cast to JSUint8Array (via package:web assumption or direct JSObject)
final chunk = result.value as JSUint8Array;
yield chunk.toDart;
}
} catch (e) {
rethrow;
}
}
Future<void> deleteBlob(String blockId) async {
await _ensureReady();
try {
await _blocksDir!.removeEntry(blockId).toDart;
} catch (e) {
// Ignore if not found
}
}
}
@@ -0,0 +1,226 @@
import 'dart:async';
import 'package:file/file.dart';
import 'package:web_file_system/src/backend/idb_inode_service.dart';
import '../web_file_system.dart';
import 'web_file.dart';
class WebDirectory extends FileSystemEntity implements Directory {
final WebFileSystem _fs;
@override
final String path;
WebDirectory(this._fs, this.path);
@override
FileSystem get fileSystem => _fs;
@override
Uri get uri => Uri.parse(path);
@override
Future<Directory> create({bool recursive = false}) async {
if (await exists()) return this;
final parentPath = _fs.path.dirname(path);
final name = _fs.path.basename(path);
if (recursive) {
await _createRecursiveSafe(path);
return this;
}
// Validate parent exists (handled by resolvepath usually throwing, or we must check)
// 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,
));
return this;
}
Future<void> _createRecursiveSafe(String p) async {
if (p == '/' || p == '.') return;
if (await _fs.type(p) != FileSystemEntityType.notFound) return;
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));
}
@override
void createSync({bool recursive = false}) {
throw UnsupportedError('Sync create not supported');
}
@override
Future<Directory> createTemp([String? prefix]) async {
final name = (prefix ?? 'temp') + _fs.uuid.v4();
final tempDir = _fs.path.join(path, name);
// Ensure path exists
if (!await exists()) {
throw FileSystemException(
'Directory does not exist', path, const OSError('ENOENT', 2));
}
final dir = WebDirectory(_fs, tempDir);
await dir.create();
return dir;
}
@override
Directory createTempSync([String? prefix]) {
throw UnsupportedError('Sync not supported');
}
@override
Future<FileSystemEntity> delete({bool recursive = false}) async {
final inode = await _fs.resolvepath(path);
final children = await _fs.idb.listChildren(inode.id);
if (children.isNotEmpty && !recursive) {
throw FileSystemException(
'Directory not empty', path, const OSError('ENOTEMPTY', 39));
}
if (recursive) {
for (final child in children) {
final childPath = _fs.path.join(path, child.name);
if (child.nodeType == 1) {
await _fs.directory(childPath).delete(recursive: true);
} else {
await _fs.file(childPath).delete();
}
}
}
await _fs.idb.deleteInode(inode.id);
return this;
}
@override
void deleteSync({bool recursive = false}) =>
throw UnsupportedError('Sync not supported');
@override
Future<bool> exists() async {
try {
final inode = await _fs.resolvepath(path);
return inode.nodeType == 1;
} catch (_) {
return false;
}
}
@override
bool existsSync() => throw UnsupportedError('Sync not supported');
@override
Stream<FileSystemEntity> list(
{bool recursive = false, bool followLinks = true}) async* {
if (!await exists()) {
throw FileSystemException(
'Directory not found', path, const OSError('ENOENT', 2));
}
final inode = await _fs.resolvepath(path);
final children = await _fs.idb.listChildren(inode.id);
for (final child in children) {
final childPath = _fs.path.join(path, child.name);
if (child.nodeType == 1) {
final dir = WebDirectory(_fs, childPath);
yield dir;
if (recursive) {
yield* dir.list(recursive: true, followLinks: followLinks);
}
} else {
yield WebFile(_fs, childPath);
}
}
}
@override
List<FileSystemEntity> listSync(
{bool recursive = false, bool followLinks = true}) {
throw UnsupportedError('Sync list not supported');
}
@override
Future<Directory> rename(String newPath) async {
final inode = await _fs.resolvepath(path);
final newParentDir = _fs.path.dirname(newPath);
final newName = _fs.path.basename(newPath);
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);
await _fs.idb.updateInode(updated);
return WebDirectory(_fs, newPath);
}
@override
Directory renameSync(String newPath) =>
throw UnsupportedError('Sync not supported');
@override
String get basename => _fs.path.basename(path);
@override
String get dirname => _fs.path.dirname(path);
@override
Directory get parent => _fs.directory(dirname);
@override
bool get isAbsolute => _fs.path.isAbsolute(path);
@override
Directory get absolute => WebDirectory(_fs, _fs.path.absolute(path));
@override
Future<FileStat> stat() => _fs.stat(path);
@override
FileStat statSync() => throw UnsupportedError('Sync not supported');
@override
Future<String> resolveSymbolicLinks() async => path;
@override
String resolveSymbolicLinksSync() =>
throw UnsupportedError('Sync not supported');
@override
Stream<FileSystemEvent> watch(
{int events = FileSystemEvent.all, bool recursive = false}) {
return const Stream.empty();
}
@override
Directory childDirectory(String basename) =>
_fs.directory(_fs.path.join(path, basename));
@override
File childFile(String basename) => _fs.file(_fs.path.join(path, basename));
@override
Link childLink(String basename) => _fs.link(_fs.path.join(path, basename));
}
@@ -0,0 +1,442 @@
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';
class WebFile extends FileSystemEntity implements File {
final WebFileSystem _fs;
@override
final String path;
WebFile(this._fs, this.path);
@override
FileSystem get fileSystem => _fs;
@override
Future<File> create({bool recursive = false, bool exclusive = false}) async {
if (await exists()) {
if (exclusive) {
throw FileSystemException(
'File already exists',
path,
const OSError('EEXIST', 17),
);
}
return this;
}
if (recursive) {
final parentDir = _fs.path.dirname(path);
if (await _fs.type(parentDir) == FileSystemEntityType.notFound) {
await _fs.directory(parentDir).create(recursive: true);
}
}
final parentPath = _fs.path.dirname(path);
final parentInode = await _fs.resolvepath(parentPath);
await _fs.idb.createInode(
Inode(
id: _fs.uuid.v4(),
parentId: parentInode.id,
name: _fs.path.basename(path),
nodeType: 0,
modified: DateTime.now().millisecondsSinceEpoch,
),
);
return this;
}
@override
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));
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,
),
);
return WebFile(_fs, newPath);
}
@override
File copySync(String newPath) => throw UnsupportedError('Sync not supported');
@override
Future<int> length() async {
final inode = await _fs.resolvepath(path);
return inode.size;
}
@override
int lengthSync() => throw UnsupportedError('Sync not supported');
@override
Future<DateTime> lastModified() async {
final inode = await _fs.resolvepath(path);
return DateTime.fromMillisecondsSinceEpoch(inode.modified);
}
@override
DateTime lastModifiedSync() => throw UnsupportedError('Sync not supported');
@override
Future<DateTime> lastAccessed() async {
return lastModified();
}
@override
DateTime lastAccessedSync() => throw UnsupportedError('Sync not supported');
@override
Future<dynamic> setLastAccessed(DateTime time) async {}
@override
void setLastAccessedSync(DateTime time) =>
throw UnsupportedError('Sync not supported');
@override
Future<dynamic> setLastModified(DateTime time) async {
final inode = await _fs.resolvepath(path);
await _fs.idb.updateInode(
Inode(
id: inode.id,
parentId: inode.parentId,
name: inode.name,
nodeType: inode.nodeType,
blobId: inode.blobId,
size: inode.size,
modified: time.millisecondsSinceEpoch,
),
);
}
@override
void setLastModifiedSync(DateTime time) =>
throw UnsupportedError('Sync not supported');
@override
Future<RandomAccessFile> open({FileMode mode = FileMode.read}) async {
throw UnsupportedError(
'RandomAccessFile not supported on web (use streams)',
);
}
@override
RandomAccessFile openSync({FileMode mode = FileMode.read}) =>
throw UnsupportedError('Sync not supported');
@override
Future<File> writeAsBytes(
List<int> bytes, {
FileMode mode = FileMode.write,
bool flush = false,
}) async {
final stream = Stream.value(bytes);
final newBlobId = await _fs.opfs.writeBlob(stream);
Inode inode;
try {
inode = await _fs.resolvepath(path);
if (mode == FileMode.append) {
throw UnsupportedError('Append not yet optimized');
}
} catch (_) {
await create(recursive: true);
inode = await _fs.resolvepath(path);
}
await _fs.idb.updateInode(
Inode(
id: inode.id,
parentId: inode.parentId,
name: inode.name,
nodeType: inode.nodeType,
blobId: newBlobId,
size: bytes.length,
modified: DateTime.now().millisecondsSinceEpoch,
),
);
return this;
}
@override
void writeAsBytesSync(
List<int> bytes, {
FileMode mode = FileMode.write,
bool flush = false,
}) {
throw UnsupportedError('Sync not supported');
}
@override
Future<File> writeAsString(
String contents, {
FileMode mode = FileMode.write,
Encoding encoding = utf8,
bool flush = false,
}) async {
return writeAsBytes(encoding.encode(contents), mode: mode, flush: flush);
}
@override
void writeAsStringSync(
String contents, {
FileMode mode = FileMode.write,
Encoding encoding = utf8,
bool flush = false,
}) {
throw UnsupportedError('Sync not supported');
}
@override
Stream<List<int>> openRead([int? start, int? end]) async* {
final inode = await _fs.resolvepath(path);
if (inode.blobId == null) return;
yield* _fs.opfs.readBlob(inode.blobId!);
}
@override
IOSink openWrite({FileMode mode = FileMode.write, Encoding encoding = utf8}) {
final controller = StreamController<List<int>>();
// Start background write but keep future to await in close()
final writeFuture = _handleWrite(controller.stream, encoding, mode);
final sink = _WebIOSink(
controller,
encoding,
onDone: () async {
await writeFuture;
},
);
return sink;
}
Future<void> _handleWrite(
Stream<List<int>> stream,
Encoding encoding,
FileMode mode,
) async {
try {
final newId = await _fs.opfs.writeBlob(stream);
Inode inode;
try {
inode = await _fs.resolvepath(path);
} catch (_) {
// Create if missing
await create(recursive: true);
inode = await _fs.resolvepath(path);
}
await _fs.idb.updateInode(
Inode(
id: inode.id,
parentId: inode.parentId,
name: inode.name,
nodeType: 0,
blobId: newId,
size:
0, // TODO: Size not returned by OPFS yet, so 0 for streamed content
modified: DateTime.now().millisecondsSinceEpoch,
),
);
} catch (e) {
throw FileSystemException('Write failed: $e', path);
}
}
@override
Future<Uint8List> readAsBytes() async {
final chunks = await openRead().toList();
return Uint8List.fromList(chunks.expand((x) => x).toList());
}
@override
Uint8List readAsBytesSync() => throw UnsupportedError('Sync not supported');
@override
Future<String> readAsString({Encoding encoding = utf8}) async {
final bytes = await readAsBytes();
return encoding.decode(bytes);
}
@override
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 str.split('\n');
}
@override
List<String> readAsLinesSync({Encoding encoding = utf8}) =>
throw UnsupportedError('Sync not supported');
@override
Future<bool> exists() async {
try {
final inode = await _fs.resolvepath(path);
return inode.nodeType == 0;
} catch (_) {
return false;
}
}
@override
bool existsSync() => throw UnsupportedError('Sync not supported');
@override
Future<File> rename(String newPath) async {
final inode = await _fs.resolvepath(path);
final newParentDir = _fs.path.dirname(newPath);
final newName = _fs.path.basename(newPath);
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,
);
await _fs.idb.updateInode(updated);
return WebFile(_fs, newPath);
}
@override
File renameSync(String newPath) =>
throw UnsupportedError('Sync not supported');
@override
Future<FileSystemEntity> delete({bool recursive = false}) async {
final inode = await _fs.resolvepath(path);
await _fs.idb.deleteInode(inode.id);
return this;
}
@override
void deleteSync({bool recursive = false}) =>
throw UnsupportedError('Sync not supported');
@override
Future<FileStat> stat() => _fs.stat(path);
@override
FileStat statSync() => throw UnsupportedError('Sync not supported');
@override
Uri get uri => Uri.parse(path);
@override
String get basename => _fs.path.basename(path);
@override
String get dirname => _fs.path.dirname(path);
@override
Directory get parent => _fs.directory(dirname);
@override
bool get isAbsolute => _fs.path.isAbsolute(path);
@override
File get absolute => WebFile(_fs, _fs.path.absolute(path));
@override
Future<String> resolveSymbolicLinks() async => path;
@override
String resolveSymbolicLinksSync() =>
throw UnsupportedError('Sync not supported');
@override
Stream<FileSystemEvent> watch({
int events = FileSystemEvent.all,
bool recursive = false,
}) {
return const Stream.empty();
}
}
class _WebIOSink implements IOSink {
final StreamController<List<int>> _controller;
final Future<void> Function()? onDone;
Encoding _encoding;
_WebIOSink(this._controller, this._encoding, {this.onDone});
@override
Encoding get encoding => _encoding;
@override
set encoding(Encoding value) => _encoding = value;
@override
void add(List<int> data) {
_controller.add(data);
}
@override
void addError(Object error, [StackTrace? stackTrace]) {
_controller.addError(error, stackTrace);
}
@override
Future addStream(Stream<List<int>> stream) {
return _controller.addStream(stream);
}
@override
Future close() async {
await _controller.close();
if (onDone != null) await onDone!();
}
@override
Future get done => _controller.done;
@override
Future flush() async {}
@override
void write(Object? object) {
add(encoding.encode(object.toString()));
}
@override
void writeAll(Iterable objects, [String separator = ""]) {
write(objects.join(separator));
}
@override
void writeCharCode(int charCode) {
add([charCode]);
}
@override
void writeln([Object? object = ""]) {
write(object);
write('\n');
}
}
@@ -0,0 +1,210 @@
import 'dart:async';
import 'dart:convert';
import 'package:file/file.dart';
import 'package:web_file_system/src/backend/idb_inode_service.dart';
import '../web_file_system.dart';
class WebLink extends FileSystemEntity implements Link {
final WebFileSystem _fs;
@override
final String path;
WebLink(this._fs, this.path);
@override
FileSystem get fileSystem => _fs;
@override
Future<Link> create(String target, {bool recursive = false}) async {
if (await exists()) {
// Should throw if exists? Standard create throws if already exists usually unless overwrite logic applied?
// create(recursive) usually implies ensuring parent exists.
throw FileSystemException(
'Link already exists',
path,
const OSError('EEXIST', 17),
);
}
if (recursive) {
final parentDir = _fs.path.dirname(path);
if (await _fs.type(parentDir) == FileSystemEntityType.notFound) {
await _fs.directory(parentDir).create(recursive: true);
}
}
// Write target path string to OPFS blob
final stream = Stream.value(utf8.encode(target));
final blobId = await _fs.opfs.writeBlob(stream);
final parentPath = _fs.path.dirname(path);
final parentInode = await _fs.resolvepath(parentPath);
await _fs.idb.createInode(
Inode(
id: _fs.uuid.v4(),
parentId: parentInode.id,
name: _fs.path.basename(path),
nodeType: 2, // Link
blobId: blobId,
size: target.length,
modified: DateTime.now().millisecondsSinceEpoch,
),
);
return this;
}
@override
void createSync(String target, {bool recursive = false}) {
throw UnsupportedError('Sync not supported');
}
@override
Future<Link> update(String target) async {
final inode = await _fs.resolvepath(path, followLinks: false);
// Write new blob
final stream = Stream.value(utf8.encode(target));
final blobId = await _fs.opfs.writeBlob(stream);
await _fs.idb.updateInode(
Inode(
id: inode.id,
parentId: inode.parentId,
name: inode.name,
nodeType: 2,
blobId: blobId,
size: target.length,
modified: DateTime.now().millisecondsSinceEpoch,
),
);
return this;
}
@override
void updateSync(String target) {
throw UnsupportedError('Sync not supported');
}
@override
Future<String> target() async {
final inode = await _fs.resolvepath(path, followLinks: false);
if (inode.nodeType != 2) {
throw FileSystemException(
'Not a link',
path,
const OSError('EINVAL', 22),
);
}
if (inode.blobId == null) return '';
final bytesList = await _fs.opfs.readBlob(inode.blobId!).toList();
final bytes = bytesList.expand((x) => x).toList();
return utf8.decode(bytes);
}
@override
String targetSync() {
throw UnsupportedError('Sync not supported');
}
@override
Future<Link> rename(String newPath) async {
final inode = await _fs.resolvepath(path, followLinks: false);
final newParentDir = _fs.path.dirname(newPath);
final newName = _fs.path.basename(newPath);
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,
);
await _fs.idb.updateInode(updated);
return WebLink(_fs, newPath);
}
@override
Link renameSync(String newPath) =>
throw UnsupportedError('Sync not supported');
@override
Future<FileSystemEntity> delete({bool recursive = false}) async {
final inode = await _fs.resolvepath(path, followLinks: false);
await _fs.idb.deleteInode(inode.id);
return this;
}
@override
void deleteSync({bool recursive = false}) =>
throw UnsupportedError('Sync not supported');
@override
Future<bool> exists() async {
try {
final inode = await _fs.resolvepath(path, followLinks: false);
return inode.nodeType == 2;
} catch (_) {
return false;
}
}
@override
bool existsSync() => throw UnsupportedError('Sync not supported');
@override
Future<FileStat> stat() async => (await _fs.stat(path));
@override
FileStat statSync() => throw UnsupportedError('Sync not supported');
@override
Uri get uri => Uri.parse(path);
@override
String get basename => _fs.path.basename(path);
@override
String get dirname => _fs.path.dirname(path);
@override
Directory get parent => _fs.directory(dirname);
@override
bool get isAbsolute => _fs.path.isAbsolute(path);
@override
Link get absolute => WebLink(_fs, _fs.path.absolute(path));
@override
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() =>
throw UnsupportedError('Sync not supported');
@override
Stream<FileSystemEvent> watch({
int events = FileSystemEvent.all,
bool recursive = false,
}) {
return const Stream.empty();
}
}
@@ -0,0 +1,260 @@
import 'dart:async';
import 'dart:convert';
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 'entities/web_directory.dart';
import 'entities/web_file.dart';
import 'entities/web_link.dart';
class WebFileSystem extends FileSystem {
final IdbInodeService _idb = IdbInodeService();
final OpfsBlockStore _opfs = OpfsBlockStore();
final Uuid _uuid = Uuid();
// Public matchers for internal use
IdbInodeService get idb => _idb;
OpfsBlockStore get opfs => _opfs;
Uuid get uuid => _uuid;
WebFileSystem();
@override
Directory directory(path) => WebDirectory(this, getPath(path));
@override
File file(path) => WebFile(this, getPath(path));
@override
Link link(path) => WebLink(this, getPath(path));
@override
p.Context get path => p.Context(style: p.Style.posix);
@override
Directory get currentDirectory => directory('/');
@override
set currentDirectory(dynamic path) {
throw UnsupportedError('Changing CWD not supported on web');
}
@override
Directory get systemTempDirectory => directory('/tmp');
@override
Future<FileSystemEntityType> type(
String path, {
bool followLinks = true,
}) async {
try {
final inode = await resolvepath(path, followLinks: followLinks);
if (inode.nodeType == 0) return FileSystemEntityType.file;
if (inode.nodeType == 1) return FileSystemEntityType.directory;
if (inode.nodeType == 2) return FileSystemEntityType.link;
return FileSystemEntityType.notFound;
} catch (e) {
return FileSystemEntityType.notFound;
}
}
@override
FileSystemEntityType typeSync(String path, {bool followLinks = true}) {
throw UnsupportedError('Sync type not supported');
}
// Internal Resolution Logic
Future<Inode> resolvepath(String pathStr, {bool followLinks = true}) async {
final normalized = path.normalize(pathStr);
final parts = path.split(normalized);
// Root is '/'
String currentId = IdbInodeService.rootId;
Inode currentInode = await _idb.getInode(currentId);
// Common recursion guard
int linkDepth = 0;
const maxLinkDepth = 20;
for (int i = 0; i < parts.length; i++) {
final part = parts[i];
if (part.isEmpty || part == '/' || part == '.') continue;
// Lookup child
final child = await _idb.getChild(currentId, part);
if (child == null) {
throw FileSystemException(
'No such file or directory',
pathStr,
const OSError('ENOENT', 2),
);
}
// If child is Link
if (child.nodeType == 2) {
// If we are at the last part, only follow if followLinks is true
if (i == parts.length - 1 && !followLinks) {
return child;
}
// Follow link
if (child.blobId != null) {
if (linkDepth++ > maxLinkDepth) {
throw FileSystemException(
'Too many levels of symbolic links',
pathStr,
const OSError('ELOOP', 40),
);
}
// Read target
final bytesList = await _opfs.readBlob(child.blobId!).toList();
final bytes = bytesList.expand((x) => x).toList();
final targetPath = utf8.decode(bytes);
// Resolve target path
// Standard: Relative to directory containing link if not absolute.
String resolvedTarget;
if (path.isAbsolute(targetPath)) {
resolvedTarget = targetPath;
} else {
// Parent path relative to root
final parentParts = parts.take(i).toList();
// join behaves weirdly with context parts, ensure root if needed
final parentStr = path.joinAll(['/', ...parentParts]);
resolvedTarget = path.normalize(path.join(parentStr, targetPath));
}
// Resolve the target inode (ALWAYS follow links when resolving intermediate link targets)
final targetInode = await resolvepath(
resolvedTarget,
followLinks: true,
);
// If we have more parts remaining in the original path, we must continue from this target
if (i < parts.length - 1) {
if (targetInode.nodeType != 1) {
throw FileSystemException(
'Not a directory',
pathStr,
const OSError('ENOTDIR', 20),
);
}
currentId = targetInode.id;
currentInode = targetInode;
continue;
} else {
// End of path, return target
return targetInode;
}
}
}
currentId = child.id;
currentInode = child;
}
return currentInode;
}
String getPath(dynamic path) {
if (path is String) return path;
if (path is FileSystemEntity) return path.path;
if (path is Uri) return path.toFilePath();
throw ArgumentError('Path must be a String, Uri, or FileSystemEntity');
}
@override
Future<FileStat> stat(String path) async {
try {
final inode = await resolvepath(path, followLinks: true);
return FileStatImpl(inode.modified, inode.size, _getType(inode.nodeType));
} catch (e) {
return FileStatImpl(0, 0, FileSystemEntityType.notFound);
}
}
@override
FileStat statSync(String path) {
throw UnsupportedError('Sync stat not supported');
}
FileSystemEntityType _getType(int nodeType) {
if (nodeType == 0) return FileSystemEntityType.file;
if (nodeType == 1) return FileSystemEntityType.directory;
if (nodeType == 2) return FileSystemEntityType.link;
return FileSystemEntityType.notFound;
}
@override
bool isFileSync(String path) =>
throw UnsupportedError('Sync isFile not supported');
@override
bool isDirectorySync(String path) =>
throw UnsupportedError('Sync isDirectory not supported');
@override
bool isLinkSync(String path) =>
throw UnsupportedError('Sync isLink not supported');
@override
Future<bool> isFile(String path) async =>
(await type(path)) == FileSystemEntityType.file;
@override
Future<bool> isDirectory(String path) async =>
(await type(path)) == FileSystemEntityType.directory;
@override
Future<bool> isLink(String path) async =>
(await type(path, followLinks: false)) == FileSystemEntityType.link;
bool get isWatchSupported => false;
@override
Future<bool> identical(String path1, String path2) async {
final s1 = await stat(path1);
final s2 = await stat(path2);
if (s1.type == FileSystemEntityType.notFound ||
s2.type == FileSystemEntityType.notFound)
return false;
final i1 = await resolvepath(path1);
final i2 = await resolvepath(path2);
return i1.id == i2.id;
}
@override
bool identicalSync(String path1, String path2) =>
throw UnsupportedError('Sync not supported');
}
class FileStatImpl implements FileStat {
final int _modified;
final int _size;
final FileSystemEntityType _type;
FileStatImpl(this._modified, this._size, this._type);
@override
DateTime get accessed => DateTime.fromMillisecondsSinceEpoch(_modified);
@override
DateTime get changed => DateTime.fromMillisecondsSinceEpoch(_modified);
@override
int get mode => 0;
@override
DateTime get modified => DateTime.fromMillisecondsSinceEpoch(_modified);
@override
int get size => _size;
@override
FileSystemEntityType get type => _type;
@override
String modeString() => 'rwxrwxrwx';
}