/** Browser runtime for Self-hosted LiveSync over the File System Access API. */ import { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; import { initialiseServiceModulesFSAPI, type FSAPIServiceModules } from "./serviceModules/FSAPIServiceModules"; import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, type LOG_LEVEL, type ObsidianLiveSyncSettings, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { collectFilesOnStorage, updateToDatabase, useOfflineScanner, } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import { useRedFlagFeatures } from "@/serviceFeatures/redFlag"; import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize"; import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig"; import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature"; import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { createLiveSyncBrowserServiceHub, type LiveSyncBrowserServiceHub, type LiveSyncBrowserServiceHubOptions, } from "@/apps/browser/createLiveSyncBrowserServiceHub"; import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost"; const SETTINGS_DIR = ".livesync"; const SETTINGS_FILE = "settings.json"; const DEFAULT_SETTINGS: Partial = { liveSync: false, syncOnSave: true, syncOnStart: false, savingDelay: 200, lessInformationInLog: false, gcDelay: 0, periodicReplication: false, periodicReplicationInterval: 60, isConfigured: false, // CouchDB settings - user needs to configure these couchDB_URI: "", couchDB_USER: "", couchDB_PASSWORD: "", couchDB_DBNAME: "", // Disable features which are not available in the WebApp. usePluginSync: false, autoSweepPlugins: false, autoSweepPluginsPeriodic: false, }; export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success"; export interface WebAppRuntimeOptions { reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void; scheduleReload?: (delayMilliseconds: number) => void; } export class WebAppRuntime { private readonly rootHandle: FileSystemDirectoryHandle; private readonly reportStatus: NonNullable; private readonly scheduleReload: NonNullable; private core: LiveSyncBaseCore | null = null; private serviceHub: LiveSyncBrowserServiceHub | null = null; private platformServiceModules: FSAPIServiceModules | null = null; private p2p: UseP2PReplicatorResult | null = null; private paneHost: P2PReplicatorPaneHost | null = null; private restartScheduled = false; constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) { this.rootHandle = rootHandle; this.reportStatus = options.reportStatus ?? (() => {}); this.scheduleReload = options.scheduleReload ?? ((delayMilliseconds) => { compatGlobal.setTimeout(() => { compatGlobal.location.reload(); }, delayMilliseconds); }); } private addLog(message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void { this.serviceHub?.API.addLog(message, level, key); } private createServiceOptions(): LiveSyncBrowserServiceHubOptions { return { getSystemVaultName: () => this.rootHandle.name || "livesync-webapp", settings: { save: async (data) => { try { await this.saveSettingsToFile(data); this.addLog("Saved to .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings"); } catch (error) { this.addLog(`Failed to save settings: ${String(error)}`, LOG_LEVEL_NOTICE, "settings"); } }, load: async () => { try { const data = await this.loadSettingsFromFile(); if (data) { this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings"); return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings; } } catch { this.addLog("Failed to load settings; using defaults", LOG_LEVEL_NOTICE, "settings"); } return DEFAULT_SETTINGS as ObsidianLiveSyncSettings; }, }, restart: { schedule: () => this.scheduleRestart(), perform: () => this.scheduleRestart(), ask: () => this.scheduleRestart(), isScheduled: () => this.restartScheduled, }, }; } private scheduleRestart(): void { if (this.restartScheduled) { return; } this.restartScheduled = true; void (async () => { this.addLog("Restart requested", LOG_LEVEL_INFO, "app-lifecycle"); await this.shutdown(); this.scheduleReload(1000); })(); } get events(): LiveSyncEventHub { if (!this.serviceHub) { throw new Error("The WebApp service hub is not initialised"); } return this.serviceHub.context.events; } get p2pPaneHost(): P2PReplicatorPaneHost { if (!this.paneHost) { throw new Error("The WebApp P2P pane host is not initialised"); } return this.paneHost; } async scanLocalFiles(): Promise { const core = this.core; const fileAccess = this.platformServiceModules?.vaultAccess; if (!core || !fileAccess) { throw new Error("The WebApp core is not initialised"); } fileAccess.fsapiAdapter.clearCache(); await fileAccess.fsapiAdapter.scanDirectory(); const log = (message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void => { this.addLog(message, level, key); }; const settings = core.services.setting.currentSettings(); const { storageFileNameMap, storageFileNames } = await collectFilesOnStorage(core, settings, log); let succeeded = true; for (const path of storageFileNames) { try { await updateToDatabase(core, log, LOG_LEVEL_INFO, storageFileNameMap[path]); } catch (error) { succeeded = false; this.addLog(`Failed to import ${path}: ${String(error)}`, LOG_LEVEL_NOTICE, "scan"); } } return succeeded; } async start(): Promise { if (this.core) { throw new Error("The WebApp runtime has already been started"); } // Create service context and hub this.serviceHub = createLiveSyncBrowserServiceHub(this.createServiceOptions()); this.addLog("Self-hosted LiveSync WebApp", LOG_LEVEL_INFO, "initialise"); this.addLog("Initialising...", LOG_LEVEL_VERBOSE, "initialise"); this.addLog(`Vault directory: ${this.rootHandle.name}`, LOG_LEVEL_VERBOSE, "initialise"); // Create LiveSync core this.core = new LiveSyncBaseCore( this.serviceHub, (core, serviceHub) => { const serviceModules = initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub); this.platformServiceModules = serviceModules; return serviceModules; }, () => [], () => [] as never[], // No add-ons (core) => { useOfflineScanner(core); useRedFlagFeatures(core); useCheckRemoteSize(core); useRemoteConfiguration(core); this.p2p = useP2PReplicatorFeature(core); this.paneHost = { services: core.services, p2p: this.p2p, }; } ); try { await this.startCore(); } catch (error) { try { await this.shutdown(); } catch (shutdownError) { this.addLog(`Failed to clean up after start failure: ${String(shutdownError)}`, LOG_LEVEL_NOTICE); } throw error; } } private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise { // Create .livesync directory if it does not exist const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true }); // Create/overwrite settings.json const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(JSON.stringify(data, null, 2)); await writable.close(); } private async loadSettingsFromFile(): Promise | null> { try { const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR); const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE); const file = await fileHandle.getFile(); const text = await file.text(); const parsed: unknown = JSON.parse(text); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error("The WebApp settings file does not contain an object"); } return parsed; } catch { // The file does not exist yet. return null; } } private async startCore(): Promise { if (!this.core) { throw new Error("Core not initialised"); } try { this.addLog("Initialising LiveSync...", LOG_LEVEL_INFO, "start"); const loadResult = await this.core.services.control.onLoad(); if (!loadResult) { this.addLog("Failed to initialise LiveSync", LOG_LEVEL_NOTICE, "start"); throw new Error("Failed to initialise LiveSync"); } await this.core.services.control.onReady(); this.addLog("LiveSync is running", LOG_LEVEL_INFO, "ready"); // Check if configured const settings = this.core.services.setting.currentSettings(); if (!settings.isConfigured) { this.addLog("LiveSync is not configured yet", LOG_LEVEL_NOTICE, "configuration"); this.showWarning("Please configure CouchDB connection in settings"); } else { this.addLog("LiveSync is configured and ready", LOG_LEVEL_INFO, "configuration"); this.addLog(`Database: ${settings.couchDB_DBNAME}`, LOG_LEVEL_VERBOSE, "configuration"); this.showSuccess("LiveSync is ready!"); } // Scan the directory to populate file cache const fileAccess = this.platformServiceModules?.vaultAccess; if (fileAccess) { this.addLog("Scanning vault directory...", LOG_LEVEL_VERBOSE, "scan"); await fileAccess.fsapiAdapter.scanDirectory(); const files = await fileAccess.fsapiAdapter.getFiles(); this.addLog(`Found ${files.length} files`, LOG_LEVEL_VERBOSE, "scan"); } } catch (error) { this.addLog(`Failed to start: ${String(error)}`, LOG_LEVEL_NOTICE, "start"); this.showError(`Failed to start: ${String(error)}`); throw error; } } async shutdown(): Promise { const core = this.core; if (!core) { return; } this.core = null; this.paneHost = null; this.addLog("Shutting down...", LOG_LEVEL_INFO, "shutdown"); const storageEventManager = this.platformServiceModules?.storageEventManager; this.platformServiceModules = null; try { if (storageEventManager) { await storageEventManager.cleanup(); } } finally { await core.services.control.onUnload(); this.p2p = null; this.addLog("Shutdown complete", LOG_LEVEL_INFO, "shutdown"); this.serviceHub = null; } } private showError(message: string): void { this.reportStatus("error", `Error: ${message}`); } private showWarning(message: string): void { this.reportStatus("warning", `Warning: ${message}`); } private showSuccess(message: string): void { this.reportStatus("success", message); } }