diff --git a/src/apps/webapp/README.md b/src/apps/webapp/README.md index 5fc34e7..e65a4e0 100644 --- a/src/apps/webapp/README.md +++ b/src/apps/webapp/README.md @@ -55,8 +55,8 @@ The built files will be in the `dist` directory. ### Usage -1. Open the webapp in your browser -2. Grant directory access when prompted +1. Open the webapp in your browser (`webapp.html`) +2. Select a vault from history or grant access to a new directory 3. Configure CouchDB connection by editing `.livesync/settings.json` in your vault - You can also copy data.json from Obsidian's plug-in folder. @@ -98,8 +98,11 @@ webapp/ │ ├── ServiceFileAccessImpl.ts │ ├── DatabaseFileAccess.ts │ └── FSAPIServiceModules.ts -├── main.ts # Application entry point -├── index.html # HTML entry +├── bootstrap.ts # Vault picker + startup orchestration +├── main.ts # LiveSync core bootstrap (after vault selected) +├── vaultSelector.ts # FileSystem handle history and permission flow +├── webapp.html # Main HTML entry +├── index.html # Redirect entry for compatibility ├── package.json ├── vite.config.ts └── README.md diff --git a/src/apps/webapp/bootstrap.ts b/src/apps/webapp/bootstrap.ts new file mode 100644 index 0000000..f186128 --- /dev/null +++ b/src/apps/webapp/bootstrap.ts @@ -0,0 +1,139 @@ +import { LiveSyncWebApp } from "./main"; +import { VaultHistoryStore, type VaultHistoryItem } from "./vaultSelector"; + +const historyStore = new VaultHistoryStore(); +let app: LiveSyncWebApp | null = null; + +function getRequiredElement(id: string): T { + const element = document.getElementById(id); + if (!element) { + throw new Error(`Missing element: #${id}`); + } + return element as T; +} + +function setStatus(kind: "info" | "warning" | "error" | "success", message: string): void { + const statusEl = getRequiredElement("status"); + statusEl.className = kind; + statusEl.textContent = message; +} + +function setBusyState(isBusy: boolean): void { + const pickNewBtn = getRequiredElement("pick-new-vault"); + pickNewBtn.disabled = isBusy; + + const historyButtons = document.querySelectorAll(".vault-item button"); + historyButtons.forEach((button) => { + button.disabled = isBusy; + }); +} + +function formatLastUsed(unixMillis: number): string { + if (!unixMillis) { + return "unknown"; + } + return new Date(unixMillis).toLocaleString(); +} + +async function renderHistoryList(): Promise { + const listEl = getRequiredElement("vault-history-list"); + const emptyEl = getRequiredElement("vault-history-empty"); + + const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]); + + listEl.innerHTML = ""; + emptyEl.style.display = items.length > 0 ? "none" : "block"; + + for (const item of items) { + const row = document.createElement("div"); + row.className = "vault-item"; + + const info = document.createElement("div"); + info.className = "vault-item-info"; + + const name = document.createElement("div"); + name.className = "vault-item-name"; + name.textContent = item.name; + + const meta = document.createElement("div"); + meta.className = "vault-item-meta"; + const label = item.id === lastUsedId ? "Last used" : "Used"; + meta.textContent = `${label}: ${formatLastUsed(item.lastUsedAt)}`; + + info.append(name, meta); + + const useButton = document.createElement("button"); + useButton.type = "button"; + useButton.textContent = "Use this vault"; + useButton.addEventListener("click", () => { + void startWithHistory(item); + }); + + row.append(info, useButton); + listEl.appendChild(row); + } + + return items; +} + +async function startWithHandle(handle: FileSystemDirectoryHandle): Promise { + setStatus("info", `Starting LiveSync with vault: ${handle.name}`); + app = new LiveSyncWebApp(handle); + await app.initialize(); + + const selectorEl = getRequiredElement("vault-selector"); + selectorEl.style.display = "none"; +} + +async function startWithHistory(item: VaultHistoryItem): Promise { + setBusyState(true); + try { + const handle = await historyStore.activateHistoryItem(item); + await startWithHandle(handle); + } catch (error) { + console.error("[Directory] Failed to open history vault:", error); + setStatus("error", `Failed to open saved vault: ${String(error)}`); + setBusyState(false); + } +} + +async function startWithNewPicker(): Promise { + setBusyState(true); + try { + const handle = await historyStore.pickNewVault(); + await startWithHandle(handle); + } catch (error) { + console.error("[Directory] Failed to pick vault:", error); + setStatus("warning", `Vault selection was cancelled or failed: ${String(error)}`); + setBusyState(false); + } +} + +async function initializeVaultSelector(): Promise { + setStatus("info", "Select a vault folder to start LiveSync."); + + const pickNewBtn = getRequiredElement("pick-new-vault"); + pickNewBtn.addEventListener("click", () => { + void startWithNewPicker(); + }); + + await renderHistoryList(); +} + +window.addEventListener("load", async () => { + try { + await initializeVaultSelector(); + } catch (error) { + console.error("Failed to initialize vault selector:", error); + setStatus("error", `Initialization failed: ${String(error)}`); + } +}); + +window.addEventListener("beforeunload", () => { + void app?.shutdown(); +}); + +(window as any).livesyncApp = { + getApp: () => app, + historyStore, +}; diff --git a/src/apps/webapp/index.html b/src/apps/webapp/index.html index 72acdc3..e8107eb 100644 --- a/src/apps/webapp/index.html +++ b/src/apps/webapp/index.html @@ -3,207 +3,10 @@ - Self-hosted LiveSync WebApp - + Self-hosted LiveSync WebApp Launcher + -
-

🔄 Self-hosted LiveSync

-

Browser-based Self-hosted LiveSync using FileSystem API

- -
- Initialising... -
- -
-

About This Application

-
    -
  • Runs entirely in your browser
  • -
  • Uses FileSystem API to access your local vault
  • -
  • Syncs with CouchDB server (like Obsidian plugin)
  • -
  • Settings stored in .livesync/settings.json
  • -
  • Real-time file watching with FileSystemObserver (Chrome 124+)
  • -
-
- -
-

How to Use

-
    -
  • Grant directory access when prompted
  • -
  • Create .livesync/settings.json in your vault folder. (Compatible with Obsidian's Self-hosted LiveSync)
  • -
  • Add your CouchDB connection details
  • -
  • Your files will be synced automatically
  • -
-
- - - - -
- - +

Redirecting to WebApp...

diff --git a/src/apps/webapp/main.ts b/src/apps/webapp/main.ts index a7ffcdd..5892bd9 100644 --- a/src/apps/webapp/main.ts +++ b/src/apps/webapp/main.ts @@ -13,9 +13,7 @@ import type { InjectableSettingService } from "@lib/services/implements/injectab import { useOfflineScanner } from "@lib/serviceFeatures/offlineScanner"; import { useRedFlagFeatures } from "@/serviceFeatures/redFlag"; import { useCheckRemoteSize } from "@lib/serviceFeatures/checkRemoteSize"; -import { SetupManager } from "@/modules/features/SetupManager"; // import { ModuleObsidianSettingsAsMarkdown } from "@/modules/features/ModuleObsidianSettingAsMarkdown"; -import { ModuleSetupObsidian } from "@/modules/features/ModuleSetupObsidian"; // import { ModuleObsidianMenu } from "@/modules/essentialObsidian/ModuleObsidianMenu"; const SETTINGS_DIR = ".livesync"; @@ -47,21 +45,18 @@ const DEFAULT_SETTINGS: Partial = { }; class LiveSyncWebApp { - private rootHandle: FileSystemDirectoryHandle | null = null; + private rootHandle: FileSystemDirectoryHandle; private core: LiveSyncBaseCore | null = null; private serviceHub: BrowserServiceHub | null = null; + constructor(rootHandle: FileSystemDirectoryHandle) { + this.rootHandle = rootHandle; + } + async initialize() { console.log("Self-hosted LiveSync WebApp"); console.log("Initializing..."); - // Request directory access - await this.requestDirectoryAccess(); - - if (!this.rootHandle) { - throw new Error("Failed to get directory access"); - } - console.log(`Vault directory: ${this.rootHandle.name}`); // Create service context and hub @@ -102,14 +97,12 @@ class LiveSyncWebApp { this.core = new LiveSyncBaseCore( this.serviceHub, (core, serviceHub) => { - return initialiseServiceModulesFSAPI(this.rootHandle!, core, serviceHub); + return initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub); }, (core) => [ // new ModuleObsidianEvents(this, core), // new ModuleObsidianSettingDialogue(this, core), // new ModuleObsidianMenu(core), - new ModuleSetupObsidian(core), - new SetupManager(core), // new ModuleObsidianSettingsAsMarkdown(core), // new ModuleLog(this, core), // new ModuleObsidianDocumentHistory(this, core), @@ -133,8 +126,6 @@ class LiveSyncWebApp { } private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise { - if (!this.rootHandle) return; - try { // Create .livesync directory if it doesn't exist const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true }); @@ -151,8 +142,6 @@ class LiveSyncWebApp { } private async loadSettingsFromFile(): Promise | null> { - if (!this.rootHandle) return null; - try { const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR); const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE); @@ -165,90 +154,6 @@ class LiveSyncWebApp { } } - private async requestDirectoryAccess() { - try { - // Check if we have a cached directory handle - const cached = await this.loadCachedDirectoryHandle(); - if (cached) { - // Verify permission (cast to any for compatibility) - try { - const permission = await (cached as any).queryPermission({ mode: "readwrite" }); - if (permission === "granted") { - this.rootHandle = cached; - console.log("[Directory] Using cached directory handle"); - return; - } - } catch (e) { - // queryPermission might not be supported, try to use anyway - console.log("[Directory] Could not verify permission, requesting new access"); - } - } - - // Request new directory access - console.log("[Directory] Requesting directory access..."); - this.rootHandle = await (window as any).showDirectoryPicker({ - mode: "readwrite", - startIn: "documents", - }); - - // Save the handle for next time - await this.saveCachedDirectoryHandle(this.rootHandle); - console.log("[Directory] Directory access granted"); - } catch (error) { - console.error("[Directory] Failed to get directory access:", error); - throw error; - } - } - - private async saveCachedDirectoryHandle(handle: FileSystemDirectoryHandle) { - try { - // Use IndexedDB to store the directory handle - const db = await this.openHandleDB(); - const transaction = db.transaction(["handles"], "readwrite"); - const store = transaction.objectStore("handles"); - await new Promise((resolve, reject) => { - const request = store.put(handle, "rootHandle"); - request.onsuccess = resolve; - request.onerror = reject; - }); - db.close(); - } catch (error) { - console.error("[Directory] Failed to cache handle:", error); - } - } - - private async loadCachedDirectoryHandle(): Promise { - try { - const db = await this.openHandleDB(); - const transaction = db.transaction(["handles"], "readonly"); - const store = transaction.objectStore("handles"); - const handle = await new Promise((resolve, reject) => { - const request = store.get("rootHandle"); - request.onsuccess = () => resolve(request.result || null); - request.onerror = reject; - }); - db.close(); - return handle; - } catch (error) { - console.error("[Directory] Failed to load cached handle:", error); - return null; - } - } - - private async openHandleDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open("livesync-webapp-handles", 1); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains("handles")) { - db.createObjectStore("handles"); - } - }; - }); - } - private async start() { if (!this.core) { throw new Error("Core not initialized"); @@ -333,21 +238,4 @@ class LiveSyncWebApp { } } -// Initialize on load -const app = new LiveSyncWebApp(); - -window.addEventListener("load", async () => { - try { - await app.initialize(); - } catch (error) { - console.error("Failed to initialize:", error); - } -}); - -// Handle page unload -window.addEventListener("beforeunload", () => { - void app.shutdown(); -}); - -// Export for debugging -(window as any).livesyncApp = app; +export { LiveSyncWebApp }; diff --git a/src/apps/webapp/vaultSelector.ts b/src/apps/webapp/vaultSelector.ts new file mode 100644 index 0000000..2935376 --- /dev/null +++ b/src/apps/webapp/vaultSelector.ts @@ -0,0 +1,192 @@ +const HANDLE_DB_NAME = "livesync-webapp-handles"; +const HANDLE_STORE_NAME = "handles"; +const LAST_USED_KEY = "meta:lastUsedVaultId"; +const VAULT_KEY_PREFIX = "vault:"; +const MAX_HISTORY_COUNT = 10; + +export type VaultHistoryItem = { + id: string; + name: string; + handle: FileSystemDirectoryHandle; + lastUsedAt: number; +}; + +type VaultHistoryValue = VaultHistoryItem; + +function makeVaultKey(id: string): string { + return `${VAULT_KEY_PREFIX}${id}`; +} + +function parseVaultId(key: string): string | null { + if (!key.startsWith(VAULT_KEY_PREFIX)) { + return null; + } + return key.slice(VAULT_KEY_PREFIX.length); +} + +function randomId(): string { + const n = Math.random().toString(36).slice(2, 10); + return `${Date.now()}-${n}`; +} + +async function hasReadWritePermission(handle: FileSystemDirectoryHandle, requestIfNeeded: boolean): Promise { + const h = handle as any; + if (typeof h.queryPermission === "function") { + const queried = await h.queryPermission({ mode: "readwrite" }); + if (queried === "granted") { + return true; + } + } + if (!requestIfNeeded) { + return false; + } + if (typeof h.requestPermission === "function") { + const requested = await h.requestPermission({ mode: "readwrite" }); + return requested === "granted"; + } + return true; +} + +export class VaultHistoryStore { + private async openHandleDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(HANDLE_DB_NAME, 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(HANDLE_STORE_NAME)) { + db.createObjectStore(HANDLE_STORE_NAME); + } + }; + }); + } + + private async withStore( + mode: IDBTransactionMode, + task: (store: IDBObjectStore) => Promise + ): Promise { + const db = await this.openHandleDB(); + try { + const tx = db.transaction([HANDLE_STORE_NAME], mode); + const store = tx.objectStore(HANDLE_STORE_NAME); + return await task(store); + } finally { + db.close(); + } + } + + private async requestAsPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async getLastUsedVaultId(): Promise { + return this.withStore("readonly", async (store) => { + const value = await this.requestAsPromise(store.get(LAST_USED_KEY)); + return typeof value === "string" ? value : null; + }); + } + + async getVaultHistory(): Promise { + return this.withStore("readonly", async (store) => { + const keys = (await this.requestAsPromise(store.getAllKeys())) as IDBValidKey[]; + const values = (await this.requestAsPromise(store.getAll())) as unknown[]; + const items: VaultHistoryItem[] = []; + for (let i = 0; i < keys.length; i++) { + const key = String(keys[i]); + const id = parseVaultId(key); + const value = values[i] as Partial | undefined; + if (!id || !value || !value.handle || !value.name) { + continue; + } + items.push({ + id, + name: String(value.name), + handle: value.handle, + lastUsedAt: Number(value.lastUsedAt || 0), + }); + } + items.sort((a, b) => b.lastUsedAt - a.lastUsedAt); + return items; + }); + } + + async saveSelectedVault(handle: FileSystemDirectoryHandle): Promise { + const now = Date.now(); + const existing = await this.getVaultHistory(); + + let matched: VaultHistoryItem | null = null; + for (const item of existing) { + try { + if (await item.handle.isSameEntry(handle)) { + matched = item; + break; + } + } catch { + // Ignore handles that cannot be compared, keep scanning. + } + } + + const item: VaultHistoryItem = { + id: matched?.id ?? randomId(), + name: handle.name, + handle, + lastUsedAt: now, + }; + + await this.withStore("readwrite", async (store): Promise => { + await this.requestAsPromise(store.put(item, makeVaultKey(item.id))); + await this.requestAsPromise(store.put(item.id, LAST_USED_KEY)); + + const merged = [...existing.filter((v) => v.id !== item.id), item].sort((a, b) => b.lastUsedAt - a.lastUsedAt); + const stale = merged.slice(MAX_HISTORY_COUNT); + for (const old of stale) { + await this.requestAsPromise(store.delete(makeVaultKey(old.id))); + } + }); + + return item; + } + + async activateHistoryItem(item: VaultHistoryItem): Promise { + const granted = await hasReadWritePermission(item.handle, true); + if (!granted) { + throw new Error("Vault permissions were not granted"); + } + + const activated: VaultHistoryItem = { + ...item, + lastUsedAt: Date.now(), + }; + + await this.withStore("readwrite", async (store): Promise => { + await this.requestAsPromise(store.put(activated, makeVaultKey(activated.id))); + await this.requestAsPromise(store.put(activated.id, LAST_USED_KEY)); + }); + + return item.handle; + } + + async pickNewVault(): Promise { + const picker = (window as any).showDirectoryPicker; + if (typeof picker !== "function") { + throw new Error("FileSystem API showDirectoryPicker is not supported in this browser"); + } + + const handle = (await picker({ + mode: "readwrite", + startIn: "documents", + })) as FileSystemDirectoryHandle; + + const granted = await hasReadWritePermission(handle, true); + if (!granted) { + throw new Error("Vault permissions were not granted"); + } + + await this.saveSelectedVault(handle); + return handle; + } +} diff --git a/src/apps/webapp/vite.config.ts b/src/apps/webapp/vite.config.ts index ea99b14..5b42608 100644 --- a/src/apps/webapp/vite.config.ts +++ b/src/apps/webapp/vite.config.ts @@ -20,7 +20,11 @@ export default defineConfig({ rollupOptions: { input: { index: path.resolve(__dirname, "index.html"), + webapp: path.resolve(__dirname, "webapp.html"), }, + external:[ + "crypto" + ] }, }, define: { diff --git a/src/apps/webapp/webapp.html b/src/apps/webapp/webapp.html new file mode 100644 index 0000000..5692f73 --- /dev/null +++ b/src/apps/webapp/webapp.html @@ -0,0 +1,267 @@ + + + + + + Self-hosted LiveSync WebApp + + + +
+

Self-hosted LiveSync

+

Browser-based Self-hosted LiveSync using FileSystem API

+ +
Initialising...
+ +
+

Select Vault Folder

+

Open a vault you already used, or pick a new folder.

+ +
+

No saved vaults yet.

+ +
+ +
+

How to Use

+
    +
  • Select a vault folder and grant permission
  • +
  • Create .livesync/settings.json in your vault folder
  • +
  • Add your CouchDB connection details
  • +
  • Your files will be synced automatically
  • +
+
+ + +
+ + + +