/** * File System Service * * Provides a virtual file system for the simulated Android environment. * Uses IndexedDB for persistent storage. * * 设计目标(V2): * - 仅在「首次初始化」或「手动 reset」时,从 public/sdcard 扫描并全量导入(meta + content)到 IndexedDB * - 常规启动只从 IndexedDB 读取,不再运行时扫描/合并 public/sdcard * - 这样预置文件与运行中写入文件拥有一致语义(可重命名/移动/删除且跨重启保持) */ import { FSNode, PresetFile } from './types'; import { FILE_SYSTEM_CONFIG } from './data/fileSystemConfig'; import { getNamespacedIndexedDbName } from './storageIsolation'; import * as TimeService from './TimeService'; // API 响应结构 interface ScanResult { totalFiles: number; totalDirectories: number; files: { path: string; uri: string; name: string; mimeType: string; size: number; modifiedAt?: number; }[]; directories: { path: string; name: string; modifiedAt?: number; }[]; } // ============================================================================ // Internal State // ============================================================================ interface FileSystemState { nodes: Map; pathIndex: Map; // path -> id initialized: boolean; } let state: FileSystemState = { nodes: new Map(), pathIndex: new Map(), initialized: false, }; // IndexedDB configuration const DB_NAME = getNamespacedIndexedDbName('SimFileSystem'); const DB_VERSION = 2; const STORE_FILES = 'files'; const STORE_METADATA = 'metadata'; const STORE_SYSTEM = 'system'; const SEED_MARKER_ID = '__fs_seed__'; // Bump this when the preset sdcard seed structure changes. // This will trigger a one-time re-import from public/sdcard on next startup. const SEED_SCHEMA_VERSION = 2 as const; type SeedMarker = { id: typeof SEED_MARKER_ID; version: typeof SEED_SCHEMA_VERSION; status: 'in_progress' | 'complete'; importedAt?: number; source?: 'public/sdcard' | 'config' | 'none' | 'legacy_migration'; note?: string; }; let db: IDBDatabase | null = null; let dbClosed = false; /** * Get a valid IDBDatabase handle, reopening the connection if needed. * Returns null only when IndexedDB is fundamentally unavailable. */ async function ensureDb(): Promise { if (db && !dbClosed) return db; try { db = await openDatabase(); dbClosed = false; return db; } catch { return null; } } // ============================================================================ // Initialization // ============================================================================ /** * 实时扫描 public/sdcard 目录 * 通过 Vite 服务器的 /api/sdcard 端点获取 */ async function scanSdcard(): Promise { // 1. Try dev-server API (only available in `npm run dev`) try { const response = await fetch('/api/sdcard'); if (response.ok) { const result = await response.json(); console.log(`[FileSystem] Scanned ${result.totalFiles} files from /api/sdcard`); return result; } } catch { // API not available — likely production build } // 2. Fallback: static manifest emitted during `npm run build` try { const response = await fetch(`${import.meta.env.BASE_URL}sdcard/manifest.json`); if (response.ok) { const result = await response.json(); console.log(`[FileSystem] Loaded ${result.totalFiles} files from static .manifest.json`); return result; } } catch { // Manifest not available either } console.log('[FileSystem] No scan API or manifest available'); return null; } /** * Shared initialization promise to prevent race conditions. * Multiple callers (e.g. OSProvider, ThemeProvider) may call initFileSystem() * concurrently; we must ensure only one initialization runs. */ let initPromise: Promise | null = null; /** * Initialize the file system * Call this on app startup */ export async function initFileSystem(): Promise { if (state.initialized) return; if (initPromise) return initPromise; initPromise = initFileSystemInternal(); return initPromise; } async function initFileSystemInternal(): Promise { try { db = await openDatabase(); dbClosed = false; await loadOrCreateState(); state.initialized = true; exposeAgentAPI(); console.log('[FileSystem] Initialized with', state.nodes.size, 'nodes'); } catch (error) { console.error('[FileSystem] Initialization failed:', error); await createBaseStateInMemory(); state.initialized = true; exposeAgentAPI(); } } async function openDatabase(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => { const database = request.result; database.onversionchange = () => { database.close(); dbClosed = true; db = null; console.warn('[FileSystem] DB connection closed due to versionchange'); }; database.onclose = () => { dbClosed = true; db = null; console.warn('[FileSystem] DB connection closed unexpectedly'); }; resolve(database); }; request.onupgradeneeded = (event) => { const database = (event.target as IDBOpenDBRequest).result; if (!database.objectStoreNames.contains(STORE_FILES)) { database.createObjectStore(STORE_FILES, { keyPath: 'id' }); } if (!database.objectStoreNames.contains(STORE_METADATA)) { const metaStore = database.createObjectStore(STORE_METADATA, { keyPath: 'id' }); metaStore.createIndex('path', 'path', { unique: true }); metaStore.createIndex('parentId', 'parentId'); } if (!database.objectStoreNames.contains(STORE_SYSTEM)) { database.createObjectStore(STORE_SYSTEM, { keyPath: 'id' }); } }; }); } async function loadOrCreateState(): Promise { const conn = await ensureDb(); if (!conn) { await createBaseStateInMemory(); return; } try { await ensureSeedImportedIfNeeded(); const savedNodes = await loadMetadataFromDB(); if (savedNodes.length === 0) { await createBaseStateInMemory(); await persistAllMetadata(); } else { state.nodes = new Map(savedNodes.map(n => [n.id, n])); state.pathIndex = new Map(savedNodes.map(n => [n.path, n.id])); } } catch (error) { console.error('[FileSystem] Failed to load state:', error); await createBaseStateInMemory(); } } async function createBaseStateInMemory(): Promise { state.nodes = new Map(); state.pathIndex = new Map(); const now = TimeService.now(); // Create root directory const root: FSNode = { id: 'root', name: '/', type: 'directory', parentId: null, path: '/', size: 0, createdAt: now, modifiedAt: now, storage: 'memory', }; state.nodes.set(root.id, root); state.pathIndex.set(root.path, root.id); // Create preset directories for (const dir of FILE_SYSTEM_CONFIG.presetStructure) { ensureDirectorySync(dir.path); } } function ensureDirectorySync(path: string): FSNode | null { const normalPath = normalizePath(path); // Check if already exists const existingId = state.pathIndex.get(normalPath); if (existingId) { const existing = state.nodes.get(existingId) || null; return existing?.type === 'directory' ? existing : null; } // Recursively create parent first const parentPath = getParentPath(normalPath); if (parentPath !== normalPath && parentPath !== '') { ensureDirectorySync(parentPath); } // Create this directory const name = getFileName(normalPath); const parentId = state.pathIndex.get(parentPath) || 'root'; const now = TimeService.now(); const node: FSNode = { id: `dir_${TimeService.now()}_${Math.random().toString(36).slice(2, 8)}`, name, type: 'directory', parentId, path: normalPath, size: 0, createdAt: now, modifiedAt: now, storage: 'memory', }; state.nodes.set(node.id, node); state.pathIndex.set(normalPath, node.id); return node; } // ============================================================================ // Public API - Read Operations // ============================================================================ /** * List contents of a directory */ export function listDirectory(path: string): FSNode[] { const normalPath = normalizePath(path); const dirId = state.pathIndex.get(normalPath); if (!dirId) return []; return Array.from(state.nodes.values()) .filter(node => node.parentId === dirId) .sort((a, b) => { // Directories first, then by name if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; return a.name.localeCompare(b.name); }); } /** * Get a node by path */ export function getNode(path: string): FSNode | null { const id = state.pathIndex.get(normalizePath(path)); return id ? state.nodes.get(id) || null : null; } /** * Check if a path exists */ export function exists(path: string): boolean { return state.pathIndex.has(normalizePath(path)); } // Blob URL 缓存 - 用于 IndexedDB 文件 const blobUrlCache = new Map(); /** * 把存储的根绝对路径(如 /sdcard/DCIM/...)转成可 fetch 的 URL,带上部署 base。 * public/sdcard/* 在 build 时被发布到 sdcard/*;根部署 base='/' 时原样返回, * demo 分支 /sim/ 子路径部署时变成 /sim/sdcard/...。非根绝对路径(http/blob/相对)原样放过。 */ function presetAssetUrl(p: string): string { if (!p.startsWith('/')) return p; return import.meta.env.BASE_URL + p.slice(1); } /** * Get the URI for displaying a file (sync version) * For preset files, returns the public path * For IndexedDB files, returns cached blob URL or null */ export function getFileUri(path: string): string | null { const node = getNode(path); if (!node || node.type !== 'file') return null; // Legacy compatibility: old DB may still contain preset nodes. // In Vite dev/build, public/sdcard/* is served at /sdcard/* so node.path is fetchable. if (node.storage === 'preset') return presetAssetUrl(node.path); // For IndexedDB files, check cache if (blobUrlCache.has(node.id)) { return blobUrlCache.get(node.id)!; } // 返回 null,调用者应该使用 getFileUriAsync return null; } /** * Get the URI for displaying a file (async version) * Creates Blob URLs for IndexedDB files and caches them */ export async function getFileUriAsync(path: string): Promise { const node = getNode(path); if (!node || node.type !== 'file') return null; if (node.storage === 'preset') return getFileUri(path); // For IndexedDB files if (blobUrlCache.has(node.id)) { return blobUrlCache.get(node.id)!; } // Load from IndexedDB and create Blob URL const blob = await loadFileFromDB(node.id); if (!blob) return null; const blobUrl = URL.createObjectURL(blob); blobUrlCache.set(node.id, blobUrl); return blobUrl; } /** * Clear a blob URL from cache (call when file is deleted) */ function clearBlobUrlCache(nodeId: string): void { const url = blobUrlCache.get(nodeId); if (url) { URL.revokeObjectURL(url); blobUrlCache.delete(nodeId); } } /** * Read file content as Blob */ export async function readFile(path: string): Promise { const node = getNode(path); if (!node || node.type !== 'file') return null; if (node.storage === 'preset') { try { const response = await fetch(presetAssetUrl(node.path)); return await response.blob(); } catch (error) { console.error('[FileSystem] Failed to fetch preset file:', error); return null; } } if (node.storage === 'indexeddb') { return loadFileFromDB(node.id); } return null; } /** * Get all media files (images and videos) */ export function getMediaFiles(type?: 'image' | 'video' | 'audio'): FSNode[] { const mimePrefix = type === 'image' ? 'image/' : type === 'video' ? 'video/' : type === 'audio' ? 'audio/' : null; return Array.from(state.nodes.values()) .filter(node => { if (node.type !== 'file') return false; if (!node.mimeType) return false; if (mimePrefix) { return node.mimeType.startsWith(mimePrefix); } // Return all media types return node.mimeType.startsWith('image/') || node.mimeType.startsWith('video/') || node.mimeType.startsWith('audio/'); }) .sort((a, b) => b.modifiedAt - a.modifiedAt); } /** * Get files matching a path pattern */ export function getFilesByPath(pathPattern: string): FSNode[] { const pattern = normalizePath(pathPattern); return Array.from(state.nodes.values()) .filter(node => node.type === 'file' && node.path.startsWith(pattern)) .sort((a, b) => b.modifiedAt - a.modifiedAt); } /** * Search files by name */ export function searchFiles(query: string, options?: { path?: string; mimeType?: string; type?: 'file' | 'directory'; }): FSNode[] { const lowerQuery = query.toLowerCase(); return Array.from(state.nodes.values()).filter(node => { if (options?.type && node.type !== options.type) return false; if (options?.mimeType && !node.mimeType?.startsWith(options.mimeType)) return false; if (options?.path && !node.path.startsWith(options.path)) return false; return node.name.toLowerCase().includes(lowerQuery); }); } // ============================================================================ // Public API - Write Operations // ============================================================================ /** * Create a directory */ export async function createDirectory(path: string): Promise { const node = ensureDirectorySync(normalizePath(path)); if (!node) { throw new Error(`[FileSystem] Cannot create directory: path exists and is not a directory: ${path}`); } await saveMetadataToDB(node); return node; } /** * Write a file */ export async function writeFile( path: string, content: Blob | ArrayBuffer | string, options?: { mimeType?: string; createdAt?: number; modifiedAt?: number } ): Promise { const normalPath = normalizePath(path); const parentPath = getParentPath(normalPath); const fileName = getFileName(normalPath); // Ensure parent directory exists await createDirectory(parentPath); // Convert to Blob const blob = content instanceof Blob ? content : new Blob([content], { type: options?.mimeType || 'application/octet-stream' }); const now = TimeService.now(); const createdAt = Number.isFinite(options?.createdAt) ? Number(options?.createdAt) : now; const modifiedAt = Number.isFinite(options?.modifiedAt) ? Number(options?.modifiedAt) : now; // Check if file already exists let node = getNode(normalPath); if (node && node.type !== 'file') { throw new Error(`[FileSystem] Cannot write file: path exists and is a directory: ${normalPath}`); } if (node && node.type === 'file') { // Update existing file node.size = blob.size; node.modifiedAt = modifiedAt; if (Number.isFinite(options?.createdAt)) node.createdAt = createdAt; node.storage = 'indexeddb'; if (options?.mimeType) node.mimeType = options.mimeType; } else { // Create new file const parentId = state.pathIndex.get(parentPath)!; node = { id: `file_${now}_${Math.random().toString(36).slice(2, 8)}`, name: fileName, type: 'file', parentId, path: normalPath, size: blob.size, mimeType: options?.mimeType || blob.type, createdAt, modifiedAt, storage: 'indexeddb', }; state.nodes.set(node.id, node); state.pathIndex.set(normalPath, node.id); } // Save to IndexedDB await saveFileToDB(node.id, blob); await saveMetadataToDB(node); return node; } /** * Editable file metadata — the descriptive fields a caller may safely patch * without breaking filesystem invariants. Structural fields (id/path/name/ * parentId/type) are excluded because changing them is a move/rename (use * ``moveNode``); derived/internal fields (size/storage) are excluded because * they must stay in sync with the actual blob and storage backend (use * ``writeFile`` to change content). */ export type EditableMetadata = Partial>; /** * Patch a file's editable metadata in place without touching its blob or * storage backend. Unlike ``writeFile``, this does not fetch/rewrite content, * does not migrate ``preset`` files into IndexedDB, and works even when the * file's blob is unavailable — it only mutates node metadata. * * Returns the updated node, or null if the path doesn't exist or isn't a file. */ export async function setMetadata( path: string, patch: EditableMetadata, ): Promise { const node = getNode(path); if (!node || node.type !== 'file') return null; const keys: (keyof EditableMetadata)[] = [ 'createdAt', 'modifiedAt', 'mimeType', 'thumbnailUri', 'width', 'height', 'duration', ]; for (const key of keys) { const value = patch[key]; if (value !== undefined) { (node as unknown as Record)[key] = value; } } await saveMetadataToDB(node); return node; } /** * Delete a file or directory */ export async function deleteNode(path: string): Promise { const node = getNode(path); if (!node) return false; if (node.type === 'directory') { // Recursively delete children const children = listDirectory(path); for (const child of children) { await deleteNode(child.path); } } else if (node.storage === 'indexeddb') { await deleteFileFromDB(node.id); } // For legacy preset files: remove from metadata only; content is still in public assets // Clear blob URL cache if exists clearBlobUrlCache(node.id); state.nodes.delete(node.id); state.pathIndex.delete(node.path); await deleteMetadataFromDB(node.id); return true; } /** * Move/rename a file or directory */ export async function moveNode(srcPath: string, destPath: string): Promise { const normalSrc = normalizePath(srcPath); const normalDest = normalizePath(destPath); const node = getNode(normalSrc); if (!node) return null; if (normalSrc === normalDest) return node; if (exists(normalDest)) return null; const newParentPath = getParentPath(normalDest); const newName = getFileName(normalDest); // Ensure destination directory exists await createDirectory(newParentPath); const newParentId = state.pathIndex.get(newParentPath); if (!newParentId) return null; const now = TimeService.now(); // Guard: prevent moving a directory into its own subtree if (node.type === 'directory') { const srcPrefix = normalSrc === '/' ? '/' : normalSrc + '/'; if (normalDest.startsWith(srcPrefix)) return null; } if (node.type === 'file') { state.pathIndex.delete(node.path); node.path = normalDest; node.name = newName; node.parentId = newParentId; node.modifiedAt = now; state.pathIndex.set(normalDest, node.id); await saveMetadataToDB(node); return node; } // Directory move: update path prefix for all descendants const affected: FSNode[] = []; const prefix = normalSrc + '/'; for (const n of state.nodes.values()) { if (n.path === normalSrc || n.path.startsWith(prefix)) affected.push(n); } // Remove old path index entries for (const n of affected) { state.pathIndex.delete(n.path); } // Update paths for (const n of affected) { if (n.id === node.id) { n.path = normalDest; n.name = newName; n.parentId = newParentId; n.modifiedAt = now; } else { const suffix = n.path.slice(normalSrc.length); n.path = normalDest + suffix; n.modifiedAt = now; } state.pathIndex.set(n.path, n.id); } await saveMetadataBatchToDB(affected); return node; } /** * Copy a file */ export async function copyFile(srcPath: string, destPath: string): Promise { const srcNode = getNode(srcPath); if (!srcNode || srcNode.type !== 'file') return null; const content = await readFile(srcPath); if (!content) return null; return writeFile(destPath, content, { mimeType: srcNode.mimeType }); } // ============================================================================ // IndexedDB Operations // ============================================================================ async function loadMetadataFromDB(): Promise { const conn = await ensureDb(); if (!conn) return []; return new Promise((resolve, reject) => { const tx = conn.transaction(STORE_METADATA, 'readonly'); const store = tx.objectStore(STORE_METADATA); const request = store.getAll(); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); }); } async function saveMetadataToDB(node: FSNode): Promise { const conn = await ensureDb(); if (!conn) return; return new Promise((resolve, reject) => { const tx = conn.transaction(STORE_METADATA, 'readwrite'); const store = tx.objectStore(STORE_METADATA); const request = store.put(node); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } async function deleteMetadataFromDB(id: string): Promise { const conn = await ensureDb(); if (!conn) return; return new Promise((resolve, reject) => { const tx = conn.transaction(STORE_METADATA, 'readwrite'); const store = tx.objectStore(STORE_METADATA); const request = store.delete(id); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } async function saveMetadataBatchToDB(nodes: FSNode[]): Promise { if (nodes.length === 0) return; const conn = await ensureDb(); if (!conn) return; const tx = conn.transaction(STORE_METADATA, 'readwrite'); const store = tx.objectStore(STORE_METADATA); for (const node of nodes) store.put(node); return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } // ============================================================================ // System Store (seed marker) // ============================================================================ async function loadSeedMarkerFromDB(): Promise { const conn = await ensureDb(); if (!conn) return null; return new Promise((resolve, reject) => { const tx = conn.transaction(STORE_SYSTEM, 'readonly'); const store = tx.objectStore(STORE_SYSTEM); const request = store.get(SEED_MARKER_ID); request.onsuccess = () => resolve((request.result as SeedMarker) || null); request.onerror = () => reject(request.error); }); } async function saveSeedMarkerToDB(marker: SeedMarker): Promise { const conn = await ensureDb(); if (!conn) return; return new Promise((resolve, reject) => { const tx = conn.transaction(STORE_SYSTEM, 'readwrite'); const store = tx.objectStore(STORE_SYSTEM); const request = store.put(marker); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } async function clearSeedMarkerFromDB(): Promise { const conn = await ensureDb(); if (!conn) return; return new Promise((resolve, reject) => { const tx = conn.transaction(STORE_SYSTEM, 'readwrite'); const store = tx.objectStore(STORE_SYSTEM); const request = store.delete(SEED_MARKER_ID); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } async function persistAllMetadata(): Promise { const conn = await ensureDb(); if (!conn) return; const tx = conn.transaction(STORE_METADATA, 'readwrite'); const store = tx.objectStore(STORE_METADATA); for (const node of state.nodes.values()) { store.put(node); } return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function saveFileToDB(id: string, content: Blob): Promise { const conn = await ensureDb(); if (!conn) return; return new Promise((resolve, reject) => { const tx = conn.transaction(STORE_FILES, 'readwrite'); const store = tx.objectStore(STORE_FILES); const request = store.put({ id, content }); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } async function loadFileFromDB(id: string): Promise { const conn = await ensureDb(); if (!conn) return null; return new Promise((resolve, reject) => { const tx = conn.transaction(STORE_FILES, 'readonly'); const store = tx.objectStore(STORE_FILES); const request = store.get(id); request.onsuccess = () => { const result = request.result; resolve(result?.content || null); }; request.onerror = () => reject(request.error); }); } async function deleteFileFromDB(id: string): Promise { const conn = await ensureDb(); if (!conn) return; return new Promise((resolve, reject) => { const tx = conn.transaction(STORE_FILES, 'readwrite'); const store = tx.objectStore(STORE_FILES); const request = store.delete(id); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } // ============================================================================ // Seed Import (public/sdcard -> IndexedDB) // ============================================================================ function inferCreatedAtFromName(fileName: string, fallback: number): number { const dateMatch = fileName.match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/); if (!dateMatch) return fallback; const ts = TimeService.parseToTimestamp( `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}T${dateMatch[4]}:${dateMatch[5]}:${dateMatch[6]}`, ); return Number.isFinite(ts) ? ts : fallback; } function makeSeedFileId(path: string): string { // Stable across runs (within one DB lifecycle). OK that it changes after reset. return `seed_${normalizePath(path).replace(/\//g, '_')}`; } async function clearFilesAndMetadataStores(): Promise { const conn = await ensureDb(); if (!conn) return; const tx = conn.transaction([STORE_METADATA, STORE_FILES], 'readwrite'); tx.objectStore(STORE_METADATA).clear(); tx.objectStore(STORE_FILES).clear(); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function seedImportFromPublicSdcard(): Promise { if (!(await ensureDb())) return; console.log('[FileSystem] Seed import: start'); // Clean slate await clearFilesAndMetadataStores(); blobUrlCache.forEach((url) => URL.revokeObjectURL(url)); blobUrlCache.clear(); await saveSeedMarkerToDB({ id: SEED_MARKER_ID, version: SEED_SCHEMA_VERSION, status: 'in_progress', importedAt: TimeService.now(), source: 'public/sdcard', }); // Build base structure in memory first await createBaseStateInMemory(); const scan = await scanSdcard(); if (scan) { for (const dir of scan.directories) ensureDirectorySync(dir.path); // Build metadata + collect items for batched fetching const fetchItems: { id: string; uri: string; node: FSNode }[] = []; for (const file of scan.files) { const normalPath = normalizePath(file.path); const parentPath = getParentPath(normalPath); ensureDirectorySync(parentPath); const parentId = state.pathIndex.get(parentPath); if (!parentId) continue; const modifiedAt = file.modifiedAt || TimeService.now(); const createdAt = inferCreatedAtFromName(file.name, modifiedAt); const id = makeSeedFileId(normalPath); const node: FSNode = { id, name: file.name, type: 'file', parentId, path: normalPath, size: file.size, mimeType: file.mimeType, createdAt, modifiedAt, storage: 'indexeddb', }; state.nodes.set(node.id, node); state.pathIndex.set(node.path, node.id); fetchItems.push({ id, uri: file.uri || normalPath, node }); } // Fetch in batches of 8 to avoid TCP connection storms under high concurrency const SEED_FETCH_BATCH = 8; const fetched: { id: string; blob: Blob }[] = []; for (let i = 0; i < fetchItems.length; i += SEED_FETCH_BATCH) { const batch = fetchItems.slice(i, i + SEED_FETCH_BATCH); const results = await Promise.all(batch.map(({ id, uri, node }) => fetch(presetAssetUrl(uri)).then(async (resp) => { if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); node.size = blob.size; node.mimeType = node.mimeType || blob.type; return { id, blob } as { id: string; blob: Blob }; }).catch((e) => { console.warn('[FileSystem] Seed import: failed to fetch file', uri, e); return null; }), )); for (const r of results) if (r) fetched.push(r); } if (fetched.length > 0) { const conn = await ensureDb(); if (conn) { const tx = conn.transaction(STORE_FILES, 'readwrite'); const store = tx.objectStore(STORE_FILES); for (const { id, blob } of fetched) { store.put({ id, content: blob }); } await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } } } else if (FILE_SYSTEM_CONFIG.presetFiles.length > 0) { console.log('[FileSystem] Seed import: scan API unavailable, fallback to config presetFiles'); for (const preset of FILE_SYSTEM_CONFIG.presetFiles) { await importPresetFileFromConfig(preset); } } else { console.warn('[FileSystem] Seed import: no scan API and no presetFiles; created empty /sdcard'); } // Persist all metadata at once (directories + files + root) await persistAllMetadata(); await saveSeedMarkerToDB({ id: SEED_MARKER_ID, version: SEED_SCHEMA_VERSION, status: 'complete', importedAt: TimeService.now(), source: scan ? 'public/sdcard' : (FILE_SYSTEM_CONFIG.presetFiles.length > 0 ? 'config' : 'none'), }); console.log('[FileSystem] Seed import: complete'); } async function importPresetFileFromConfig(preset: PresetFile): Promise { const normalPath = normalizePath(preset.path); const parentPath = getParentPath(normalPath); ensureDirectorySync(parentPath); const parentId = state.pathIndex.get(parentPath); if (!parentId) return; const fileName = getFileName(normalPath); const now = TimeService.now(); const createdAt = inferCreatedAtFromName(fileName, now); const id = makeSeedFileId(normalPath); const node: FSNode = { id, name: fileName, type: 'file', parentId, path: normalPath, size: preset.size, mimeType: preset.mimeType, createdAt, modifiedAt: createdAt, storage: 'indexeddb', width: preset.width, height: preset.height, duration: preset.duration, }; state.nodes.set(node.id, node); state.pathIndex.set(node.path, node.id); try { const resp = await fetch(presetAssetUrl(preset.uri)); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); node.size = blob.size; node.mimeType = node.mimeType || blob.type; await saveFileToDB(node.id, blob); } catch (e) { console.warn('[FileSystem] Seed import (config): failed to fetch preset', preset.path, e); } } async function migrateLegacyPresetNodesToIndexedDB(nodes: FSNode[]): Promise { if (!(await ensureDb())) return; console.log('[FileSystem] Legacy migration: converting preset nodes to indexeddb'); // Restore to memory to reuse helpers and keep pathIndex consistent state.nodes = new Map(nodes.map((n) => [n.id, n])); state.pathIndex = new Map(nodes.map((n) => [n.path, n.id])); for (const node of state.nodes.values()) { if (node.type !== 'file') continue; if (node.storage !== 'preset') continue; try { const resp = await fetch(node.path); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); await saveFileToDB(node.id, blob); node.storage = 'indexeddb'; node.size = blob.size; node.mimeType = node.mimeType || blob.type; } catch (e) { console.warn('[FileSystem] Legacy migration: failed to migrate', node.path, e); } } await persistAllMetadata(); } async function ensureSeedImportedIfNeeded(): Promise { if (!(await ensureDb())) return; const marker = await loadSeedMarkerFromDB(); if (marker?.status === 'complete') { if (marker.version !== SEED_SCHEMA_VERSION) { console.warn('[FileSystem] Seed schema changed, re-importing'); await seedImportFromPublicSdcard(); } return; } // If import was interrupted, restart from scratch if (marker?.status === 'in_progress') { console.warn('[FileSystem] Seed marker in_progress, re-importing'); await seedImportFromPublicSdcard(); return; } // Backward compatibility: older DB might exist without marker const existingNodes = await loadMetadataFromDB(); if (existingNodes.length > 0) { const hasPreset = existingNodes.some((n) => n.type === 'file' && n.storage === 'preset'); await saveSeedMarkerToDB({ id: SEED_MARKER_ID, version: SEED_SCHEMA_VERSION, status: 'in_progress', importedAt: TimeService.now(), source: 'legacy_migration', note: hasPreset ? 'migrating preset nodes' : 'marker added to existing db', }); if (hasPreset) { await migrateLegacyPresetNodesToIndexedDB(existingNodes); } await saveSeedMarkerToDB({ id: SEED_MARKER_ID, version: SEED_SCHEMA_VERSION, status: 'complete', importedAt: TimeService.now(), source: 'legacy_migration', }); return; } // Fresh DB await seedImportFromPublicSdcard(); } // ============================================================================ // Utility Functions // ============================================================================ function normalizePath(path: string): string { // Remove trailing slashes and normalize multiple slashes return path.replace(/\/+/g, '/').replace(/\/$/, '') || '/'; } function getParentPath(path: string): string { const parts = path.split('/').filter(Boolean); parts.pop(); return '/' + parts.join('/'); } function getFileName(path: string): string { return path.split('/').filter(Boolean).pop() || ''; } /** * Format file size for display */ export function formatFileSize(bytes: number): string { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'; } /** * Get display name for a directory */ export function getDirectoryDisplayName(path: string): string { const preset = FILE_SYSTEM_CONFIG.presetStructure.find(d => d.path === path); return preset?.displayName || getFileName(path); } export function snapshotFileSystem(): { nodes: Omit[] } { const nodes = Array.from(state.nodes.values()) .map((node) => { const { thumbnailUri, ...meta } = node; return meta; }) .sort((a, b) => a.path.localeCompare(b.path)); return { nodes }; } function clearRuntimeState(): void { state.nodes.clear(); state.pathIndex.clear(); state.initialized = false; initPromise = null; blobUrlCache.forEach((url) => URL.revokeObjectURL(url)); blobUrlCache.clear(); } /** * Clear all data from IndexedDB stores without deleting the database. * Uses the existing connection — avoids 'deleteDatabase blocked' when * transactions (e.g. seed import) are still in-flight. * After clearing, the next initFileSystem() will re-seed from scratch. */ export async function clearFileSystemDB(): Promise { clearRuntimeState(); const conn = await ensureDb(); if (!conn) return; const storeNames = [STORE_FILES, STORE_METADATA, STORE_SYSTEM].filter( name => conn.objectStoreNames.contains(name), ); if (storeNames.length === 0) return; const tx = conn.transaction(storeNames, 'readwrite'); for (const name of storeNames) { tx.objectStore(name).clear(); } await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } export async function destroyFileSystemDB(): Promise { if (db && !dbClosed) { db.close(); dbClosed = true; } db = null; clearRuntimeState(); await new Promise((resolve, reject) => { const request = indexedDB.deleteDatabase(DB_NAME); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error ?? new Error('deleteDatabase failed')); request.onblocked = () => reject(new Error('deleteDatabase blocked')); }); } // ============================================================================ // Agent API // ============================================================================ /** * 刷新文件系统 * * V2 语义:运行时不再扫描/合并 public/sdcard,预置内容更新统一通过 reset 完成。 */ export async function refreshFileSystem(): Promise { console.warn('[FileSystem] refresh() is deprecated. Use __SIM_FS__.reset() to re-import from public/sdcard.'); } /** * 重置文件系统(清除 IndexedDB 并重新创建) * 用于开发调试 */ export async function resetFileSystem(): Promise { console.log('[FileSystem] Resetting...'); const conn = await ensureDb(); if (!conn) { console.error('[FileSystem] Reset failed to open DB'); await createBaseStateInMemory(); return; } await clearSeedMarkerFromDB(); await seedImportFromPublicSdcard(); console.log('[FileSystem] Reset complete, now has', state.nodes.size, 'nodes'); } function exposeAgentAPI(): void { window.__SIM_FS__ = { // Directory operations list: listDirectory, mkdir: createDirectory, // File operations read: readFile, write: writeFile, setMetadata, delete: deleteNode, move: moveNode, copy: copyFile, // Query stat: getNode, exists, search: searchFiles, getMedia: getMediaFiles, getByPath: getFilesByPath, // Utils getUri: getFileUri, getUriAsync: getFileUriAsync, formatSize: formatFileSize, getDisplayName: getDirectoryDisplayName, // Refresh & Reset refresh: refreshFileSystem, reset: resetFileSystem, }; }