import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters"; import type { IStorageEventTypeGuardAdapter, IStorageEventPersistenceAdapter, IStorageEventWatchAdapter, IStorageEventStatusAdapter, IStorageEventConverterAdapter, IStorageEventWatchHandlers, } from "@vrtmrz/livesync-commonlib/compat/managers/adapters"; import type { FileEventItemSentinel } from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager"; import type { FSAPIFile, FSAPIFolder } from "@/apps/webapp/adapters/FSAPITypes"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { WebAppLog } from "@/apps/webapp/WebAppLog"; type FileSystemObserverRecord = { changedHandle?: FileSystemFileHandle | FileSystemDirectoryHandle; relativePathComponents?: readonly string[]; type: "appeared" | "disappeared" | "modified" | "moved" | "unknown" | "errored"; }; type FileSystemObserverInstance = { observe(handle: FileSystemDirectoryHandle, options: { recursive: boolean }): Promise; disconnect(): void; }; type FileSystemObserverConstructor = new ( callback: (records: readonly FileSystemObserverRecord[]) => void | Promise ) => FileSystemObserverInstance; type GlobalWithFileSystemObserver = typeof compatGlobal & { FileSystemObserver?: FileSystemObserverConstructor; }; /** * FileSystem API-specific type guard adapter */ class FSAPITypeGuardAdapter implements IStorageEventTypeGuardAdapter { isFile(file: unknown): file is FSAPIFile { return !!( file && typeof file === "object" && "path" in file && "stat" in file && "handle" in file && !(file as { isFolder?: boolean }).isFolder ); } isFolder(item: unknown): item is FSAPIFolder { return !!( item && typeof item === "object" && "path" in item && (item as { isFolder?: boolean }).isFolder === true && "handle" in item ); } } /** * FileSystem API-specific persistence adapter (IndexedDB-based snapshot) */ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter { private dbName = "livesync-webapp-snapshot"; private storeName = "snapshots"; private snapshotKey = "file-events"; constructor(private readonly addLog: WebAppLog) {} private async openDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, 1); request.onerror = () => reject(request.error ?? new Error("Failed to open the WebApp snapshot database")); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains(this.storeName)) { db.createObjectStore(this.storeName); } }; }); } async saveSnapshot(snapshot: (FileEventItem | FileEventItemSentinel)[]): Promise { try { const db = await this.openDB(); const transaction = db.transaction([this.storeName], "readwrite"); const store = transaction.objectStore(this.storeName); await new Promise((resolve, reject) => { const request = store.put(snapshot, this.snapshotKey); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error ?? new Error("Failed to save the WebApp storage-event snapshot")); }); db.close(); } catch (error) { this.addLog(`Failed to save snapshot: ${String(error)}`, LOG_LEVEL_NOTICE, "fsapi-snapshot"); } } async loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null> { try { const db = await this.openDB(); const transaction = db.transaction([this.storeName], "readonly"); const store = transaction.objectStore(this.storeName); const result = await new Promise<(FileEventItem | FileEventItemSentinel)[] | null>((resolve, reject) => { const request = store.get(this.snapshotKey); request.onsuccess = () => resolve((request.result as (FileEventItem | FileEventItemSentinel)[] | undefined) ?? null); request.onerror = () => reject(request.error ?? new Error("Failed to load the WebApp storage-event snapshot")); }); db.close(); return result; } catch { return null; } } } /** * FileSystem API-specific status adapter (console logging) */ class FSAPIStatusAdapter implements IStorageEventStatusAdapter { private lastUpdate = 0; private updateInterval = 5000; // Update every 5 seconds constructor(private readonly addLog: WebAppLog) {} updateStatus(status: { batched: number; processing: number; totalQueued: number }): void { const now = Date.now(); if (now - this.lastUpdate > this.updateInterval) { if (status.totalQueued > 0 || status.processing > 0) { this.addLog( `Batched: ${status.batched}, Processing: ${status.processing}, Total queued: ${status.totalQueued}`, LOG_LEVEL_VERBOSE, "storage-events" ); } this.lastUpdate = now; } } } /** * FileSystem API-specific converter adapter */ class FSAPIConverterAdapter implements IStorageEventConverterAdapter { toFileInfo(file: FSAPIFile, deleted?: boolean): UXFileInfoStub { const pathParts = file.path.split("/"); const name = pathParts[pathParts.length - 1] || file.handle.name; return { name: name, path: file.path, stat: file.stat, deleted: deleted, isFolder: false, }; } toInternalFileInfo(p: FilePath): UXInternalFileInfoStub { const pathParts = p.split("/"); const name = pathParts[pathParts.length - 1] || ""; return { name: name, path: p, isInternal: true, stat: undefined, }; } } /** * FileSystem API-specific watch adapter using FileSystemObserver (Chrome only) */ class FSAPIWatchAdapter implements IStorageEventWatchAdapter { private observer: FileSystemObserverInstance | null = null; constructor( private rootHandle: FileSystemDirectoryHandle, private readonly addLog: WebAppLog ) {} async beginWatch(handlers: IStorageEventWatchHandlers): Promise { // Use FileSystemObserver when the browser provides it. const FileSystemObserver = (compatGlobal as GlobalWithFileSystemObserver).FileSystemObserver; if (!FileSystemObserver) { this.addLog( "FileSystemObserver is not available; file watching is disabled", LOG_LEVEL_INFO, "fsapi-watch" ); this.addLog("Use 'Scan local files' after external changes", LOG_LEVEL_INFO, "fsapi-watch"); return Promise.resolve(); } try { this.observer = new FileSystemObserver(async (records) => { for (const record of records) { const changedHandle = record.changedHandle; const relativePathComponents = record.relativePathComponents; const type = record.type; // "appeared", "disappeared", "modified", "moved", "unknown", "errored" // Build relative path const relativePath = relativePathComponents ? relativePathComponents.join("/") : ""; // Skip .livesync directory to avoid infinite loops if (relativePath.startsWith(".livesync/") || relativePath === ".livesync") { continue; } this.addLog(`${type}: ${relativePath}`, LOG_LEVEL_VERBOSE, "filesystem-observer"); // Convert to our event handlers try { if (type === "appeared" || type === "modified") { if (changedHandle && changedHandle.kind === "file") { const file = await changedHandle.getFile(); const fileInfo = { path: relativePath, stat: { size: file.size, mtime: file.lastModified, ctime: file.lastModified, type: "file" as const, }, handle: changedHandle, }; if (type === "appeared") { handlers.onCreate(fileInfo, undefined); } else { handlers.onChange(fileInfo, undefined); } } } else if (type === "disappeared") { const fileInfo = { path: relativePath, stat: { size: 0, mtime: Date.now(), ctime: Date.now(), type: "file" as const, }, handle: null as unknown as FileSystemFileHandle, // No handle available for disappeared files }; handlers.onDelete(fileInfo, undefined); } else if (type === "moved") { // Handle as delete + create // Note: FileSystemObserver provides both old and new paths in some cases // For simplicity, we'll treat it as a modification if (changedHandle && changedHandle.kind === "file") { const file = await changedHandle.getFile(); const fileInfo = { path: relativePath, stat: { size: file.size, mtime: file.lastModified, ctime: file.lastModified, type: "file" as const, }, handle: changedHandle, }; handlers.onChange(fileInfo, undefined); } } } catch (error) { this.addLog( `Error processing ${type} event for ${relativePath}: ${String(error)}`, LOG_LEVEL_NOTICE, "filesystem-observer" ); } } }); // Start observing await this.observer.observe(this.rootHandle, { recursive: true }); this.addLog("FileSystemObserver started successfully", LOG_LEVEL_INFO, "fsapi-watch"); } catch (error) { this.addLog(`Failed to start FileSystemObserver: ${String(error)}`, LOG_LEVEL_NOTICE, "fsapi-watch"); this.addLog("Falling back to manual sync mode", LOG_LEVEL_INFO, "fsapi-watch"); } return Promise.resolve(); } async stopWatch(): Promise { if (this.observer) { try { this.observer.disconnect(); this.observer = null; this.addLog("FileSystemObserver stopped", LOG_LEVEL_INFO, "fsapi-watch"); } catch (error) { this.addLog(`Error stopping observer: ${String(error)}`, LOG_LEVEL_NOTICE, "fsapi-watch"); } } } } /** * Composite adapter for FileSystem API StorageEventManager */ export class FSAPIStorageEventManagerAdapter implements IStorageEventManagerAdapter { readonly typeGuard: FSAPITypeGuardAdapter; readonly persistence: FSAPIPersistenceAdapter; readonly watch: FSAPIWatchAdapter; readonly status: FSAPIStatusAdapter; readonly converter: FSAPIConverterAdapter; constructor(rootHandle: FileSystemDirectoryHandle, addLog: WebAppLog) { this.typeGuard = new FSAPITypeGuardAdapter(); this.persistence = new FSAPIPersistenceAdapter(addLog); this.watch = new FSAPIWatchAdapter(rootHandle, addLog); this.status = new FSAPIStatusAdapter(addLog); this.converter = new FSAPIConverterAdapter(); } }