adding packages
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Example
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
flutter run --web-header=Cross-Origin-Opener-Policy=same-origin --web-header=Cross-Origin-Embedder-Policy=require-corp -d chrome
|
||||
```
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:web_file_system/web_file_system.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Web File System Demo',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const FileSystemDemo(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FileSystemDemo extends StatefulWidget {
|
||||
const FileSystemDemo({super.key});
|
||||
|
||||
@override
|
||||
State<FileSystemDemo> createState() => _FileSystemDemoState();
|
||||
}
|
||||
|
||||
class _FileSystemDemoState extends State<FileSystemDemo> {
|
||||
final WebFileSystem _fs = WebFileSystem();
|
||||
final List<String> _logs = [];
|
||||
String _currentPath = '/';
|
||||
List<FileSystemEntity> _files = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshFiles();
|
||||
}
|
||||
|
||||
void _log(String message) {
|
||||
setState(() {
|
||||
_logs.add('${DateTime.now().toIso8601String()}: $message');
|
||||
// Keep last 50 logs
|
||||
if (_logs.length > 50) {
|
||||
_logs.removeAt(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refreshFiles() async {
|
||||
try {
|
||||
final dir = _fs.directory(_currentPath);
|
||||
if (await dir.exists()) {
|
||||
final files = await dir.list().toList();
|
||||
setState(() {
|
||||
_files = files;
|
||||
});
|
||||
} else {
|
||||
// Create root if missing (should be auto-created by service but just in case)
|
||||
if (_currentPath == '/') {
|
||||
// Root always exists virtually in our logic, but let's Ensure
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log('Error listing: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createFile() async {
|
||||
try {
|
||||
final name = 'test_${DateTime.now().millisecondsSinceEpoch}.txt';
|
||||
final file = _fs.file('$_currentPath/$name');
|
||||
await file.writeAsString('Hello Web FS at ${DateTime.now()}');
|
||||
_log('Created file: $name');
|
||||
await _refreshFiles();
|
||||
} catch (e) {
|
||||
_log('Error creating file: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createDir() async {
|
||||
try {
|
||||
final name = 'dir_${DateTime.now().millisecondsSinceEpoch}';
|
||||
await _fs.directory('$_currentPath/$name').create();
|
||||
_log('Created dir: $name');
|
||||
await _refreshFiles();
|
||||
} catch (e) {
|
||||
_log('Error creating dir: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(FileSystemEntity entity) async {
|
||||
try {
|
||||
await entity.delete(recursive: true);
|
||||
_log('Deleted: ${entity.basename}');
|
||||
await _refreshFiles();
|
||||
} catch (e) {
|
||||
_log('Error deleting: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _getSize(FileSystemEntity entity) async {
|
||||
if (entity is File) {
|
||||
return await entity.length();
|
||||
} else if (entity is Directory) {
|
||||
int total = 0;
|
||||
try {
|
||||
await for (final child in entity.list(recursive: true)) {
|
||||
if (child is File) {
|
||||
total += await child.length();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors
|
||||
}
|
||||
return total;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
const suffixes = ['B', 'KB', 'MB', 'GB'];
|
||||
var i = 0;
|
||||
double size = bytes.toDouble();
|
||||
while (size > 1024 && i < suffixes.length - 1) {
|
||||
size /= 1024;
|
||||
i++;
|
||||
}
|
||||
return '${size.toStringAsFixed(1)} ${suffixes[i]}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Web File System ($_currentPath)'),
|
||||
actions: [
|
||||
// Refresh icon
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _refreshFiles,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _createFile, child: const Text('New File')),
|
||||
ElevatedButton(
|
||||
onPressed: _createDir, child: const Text('New Directory')),
|
||||
if (_currentPath != '/')
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_currentPath = _fs.path.dirname(_currentPath);
|
||||
});
|
||||
_refreshFiles();
|
||||
},
|
||||
child: const Text('Go Up')),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ListView.builder(
|
||||
itemCount: _files.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entity = _files[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
entity is Directory ? Icons.folder : Icons.description,
|
||||
color: entity is Directory ? Colors.amber : Colors.blue,
|
||||
),
|
||||
title: Text(entity.basename),
|
||||
subtitle: FutureBuilder<int>(
|
||||
future: _getSize(entity),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(_formatBytes(snapshot.data!));
|
||||
}
|
||||
return const Text('Loading...');
|
||||
}),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => _delete(entity),
|
||||
),
|
||||
onTap: () {
|
||||
if (entity is Directory) {
|
||||
setState(() {
|
||||
_currentPath = entity.path;
|
||||
});
|
||||
_refreshFiles();
|
||||
} else if (entity is File) {
|
||||
entity.readAsString().then((content) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(entity.basename),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(content)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'))
|
||||
],
|
||||
));
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const Text('Logs:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Container(
|
||||
color: Colors.black12,
|
||||
child: ListView.builder(
|
||||
itemCount: _logs.length,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Text(_logs[_logs.length - 1 - index],
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
name: example
|
||||
description: Demo for web_file_system
|
||||
publish_to: 'none'
|
||||
version: 0.1.0
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
web_file_system:
|
||||
path: ../
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
<meta charset="UTF-8">
|
||||
<title>Web File System Demo</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "Web File System",
|
||||
"short_name": "Web File System",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// A high-performance, asynchronous file system for the web.
|
||||
library web_file_system;
|
||||
|
||||
export 'package:file/file.dart';
|
||||
export 'src/web_file_system.dart';
|
||||
@@ -0,0 +1,22 @@
|
||||
name: web_file_system
|
||||
description: A high-performance, asynchronous file system for the web using IDB and OPFS.
|
||||
version: 0.0.1
|
||||
publish_to: 'none'
|
||||
homepage: https://github.com/fluttercommunity/flutter_whatsnew
|
||||
maintainer: Rody Davis (@rodydavis)
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.0
|
||||
flutter: ^3.38.5
|
||||
|
||||
dependencies:
|
||||
file: ^7.0.0
|
||||
path: ^1.9.0
|
||||
web: ^1.1.1
|
||||
uuid: ^4.0.0
|
||||
mime: ^1.0.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^6.0.0
|
||||
test: ^1.25.0
|
||||
@@ -0,0 +1,165 @@
|
||||
@TestOn('browser')
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:web_file_system/web_file_system.dart';
|
||||
|
||||
void main() {
|
||||
late WebFileSystem fs;
|
||||
|
||||
setUp(() async {
|
||||
// For unit testing in a persistent browser environment, concurrent tests might conflict.
|
||||
// Ideally we would mock the backend or use unique DB names.
|
||||
// For NOW, we use the default SINGLE DB implementation but try to use unique paths.
|
||||
fs = WebFileSystem();
|
||||
});
|
||||
|
||||
group('Functional Correctness', () {
|
||||
test('Create and read text file', () async {
|
||||
final file = fs.file(
|
||||
'/hello_${DateTime.now().millisecondsSinceEpoch}.txt',
|
||||
);
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsString('Hello Hybrid FS');
|
||||
|
||||
expect(await file.exists(), isTrue);
|
||||
expect(await file.readAsString(), equals('Hello Hybrid FS'));
|
||||
});
|
||||
|
||||
test('Directory creation and listing', () async {
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
await fs.directory('/assets_$uniqueId/images').create(recursive: true);
|
||||
await fs
|
||||
.file('/assets_$uniqueId/images/logo.png')
|
||||
.writeAsString('png-data');
|
||||
await fs.file('/assets_$uniqueId/readme.md').writeAsString('read me');
|
||||
|
||||
final dir = fs.directory('/assets_$uniqueId');
|
||||
final entities = await dir.list(recursive: true).toList();
|
||||
|
||||
expect(entities.length, equals(3)); // images, logo.png, readme.md
|
||||
// Note: order is not guaranteed usually, but CoW VFS might map order
|
||||
// Paths checking
|
||||
final paths = entities.map((e) => e.path).toList();
|
||||
expect(paths, contains('/assets_$uniqueId/images'));
|
||||
expect(paths, contains('/assets_$uniqueId/images/logo.png'));
|
||||
expect(paths, contains('/assets_$uniqueId/readme.md'));
|
||||
});
|
||||
|
||||
test('Rename directory updates child paths', () async {
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final folder = '/folder_$uniqueId';
|
||||
final renamed = '/renamed_$uniqueId';
|
||||
|
||||
await fs.directory(folder).create();
|
||||
await fs.file('$folder/file.txt').writeAsString('content');
|
||||
|
||||
await fs.directory(folder).rename(renamed);
|
||||
|
||||
expect(await fs.directory(folder).exists(), isFalse);
|
||||
expect(await fs.file('$folder/file.txt').exists(), isFalse);
|
||||
|
||||
expect(await fs.directory(renamed).exists(), isTrue);
|
||||
expect(await fs.file('$renamed/file.txt').exists(), isTrue);
|
||||
|
||||
expect(
|
||||
await fs.file('$renamed/file.txt').readAsString(),
|
||||
equals('content'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Symbolic Link creation and resolution', () async {
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final targetPath = '/target_$uniqueId.txt';
|
||||
final linkPath = '/link_$uniqueId';
|
||||
|
||||
// Create target
|
||||
await fs.file(targetPath).writeAsString('target-content');
|
||||
|
||||
// Create link
|
||||
await fs.link(linkPath).create(targetPath);
|
||||
|
||||
// Verify link exists
|
||||
expect(await fs.link(linkPath).exists(), isTrue);
|
||||
expect(
|
||||
await fs.type(linkPath, followLinks: false),
|
||||
equals(FileSystemEntityType.link),
|
||||
);
|
||||
|
||||
// Verify link resolves to target content
|
||||
expect(await fs.file(linkPath).readAsString(), equals('target-content'));
|
||||
|
||||
// Verify target()
|
||||
expect(await fs.link(linkPath).target(), equals(targetPath));
|
||||
|
||||
// Verify traversing through link (directory)
|
||||
final targetDir = '/dir_$uniqueId';
|
||||
final linkDir = '/link_dir_$uniqueId';
|
||||
await fs.directory(targetDir).create();
|
||||
await fs.file('$targetDir/child.txt').writeAsString('child-content');
|
||||
await fs.link(linkDir).create(targetDir);
|
||||
|
||||
expect(
|
||||
await fs.file('$linkDir/child.txt').readAsString(),
|
||||
equals('child-content'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Benchmarks', () {
|
||||
test('BENCHMARK: Create 100 small files (Inode Stress)', () async {
|
||||
// Reduced from 1000 for CI stability in this environment
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final count = 100;
|
||||
final futures = <Future>[];
|
||||
|
||||
await fs.directory('/bulk_$uniqueId').create();
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
for (var i = 0; i < count; i++) {
|
||||
futures.add(
|
||||
fs.file('/bulk_$uniqueId/file_$i.txt').writeAsString('small data $i'),
|
||||
);
|
||||
}
|
||||
await Future.wait(futures);
|
||||
stopwatch.stop();
|
||||
|
||||
print('Created $count files in ${stopwatch.elapsedMilliseconds}ms');
|
||||
|
||||
final listWatch = Stopwatch()..start();
|
||||
final files = await fs.directory('/bulk_$uniqueId').list().length;
|
||||
listWatch.stop();
|
||||
|
||||
expect(files, equals(count));
|
||||
print('Listed $count files in ${listWatch.elapsedMilliseconds}ms');
|
||||
});
|
||||
|
||||
test('BENCHMARK: Read/Write 10MB file (Streaming)', () async {
|
||||
// Reduced from 50MB to 10MB for quicker test cycle
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final chunkSize = 1024 * 1024;
|
||||
final chunk = Uint8List(chunkSize);
|
||||
for (int i = 0; i < chunkSize; i++) chunk[i] = i % 256;
|
||||
|
||||
final file = fs.file('/video_$uniqueId.mp4');
|
||||
final sink = file.openWrite();
|
||||
|
||||
final writeWatch = Stopwatch()..start();
|
||||
for (int i = 0; i < 10; i++) {
|
||||
// 10MB
|
||||
sink.add(chunk);
|
||||
}
|
||||
await sink.close();
|
||||
writeWatch.stop();
|
||||
|
||||
print('Wrote 10MB in ${writeWatch.elapsedMilliseconds}ms');
|
||||
|
||||
int totalBytes = 0;
|
||||
await for (final buffer in file.openRead()) {
|
||||
totalBytes += buffer.length;
|
||||
}
|
||||
|
||||
expect(totalBytes, equals(10 * chunkSize));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user