From 28effab1edc1fb68536b745ee29abde3594bf953 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Sun, 24 May 2026 21:51:02 +0200 Subject: [PATCH] expose Ignis API, implement shared ws client --- apps/ignis-server/Dockerfile | 5 - apps/ignis-server/server/config.js | 13 +- apps/ignis-server/server/index.js | 5 +- .../server/plugin-system/manager.js | 26 ++ .../plugins/headless-sync/broadcaster.js | 39 +-- .../server/plugins/headless-sync/index.js | 13 - .../obsidian/src/core-sync-guard.js | 18 +- .../headless-sync/obsidian/src/log-viewer.js | 36 ++- .../headless-sync/obsidian/src/main.js | 13 +- .../obsidian/src/settings-tab.js | 6 +- .../obsidian/src/sync-status-bar.js | 63 ++--- .../headless-sync/obsidian/src/ws-listener.js | 153 ---------- packages/bridge/src/settings/general-tab.js | 62 ++-- packages/bridge/src/settings/inject.js | 10 +- packages/bridge/src/settings/plugin-tabs.js | 37 +-- .../bridge/src/settings/server-plugins-tab.js | 49 +--- packages/bridge/src/status-bar.js | 45 ++- packages/server-core/src/ws.js | 172 ++++++++++- packages/shim/src/fs/index.js | 3 +- packages/shim/src/fs/watcher-client.js | 164 ++++------- packages/shim/src/ignis-api.js | 28 ++ packages/shim/src/init.js | 36 +-- packages/shim/src/loader.js | 15 +- packages/shim/src/ui-registry.js | 1 - packages/shim/src/virtual-plugin-loader.js | 163 ++++++++--- packages/shim/src/ws-client.js | 267 ++++++++++++++++++ packages/ui/src/bootstrap.js | 37 --- .../layout/PluginInstallDialog.svelte | 89 ------ packages/ui/src/index.js | 1 - 29 files changed, 824 insertions(+), 745 deletions(-) delete mode 100644 apps/ignis-server/server/plugins/headless-sync/obsidian/src/ws-listener.js create mode 100644 packages/shim/src/ignis-api.js create mode 100644 packages/shim/src/ws-client.js delete mode 100644 packages/ui/src/components/layout/PluginInstallDialog.svelte diff --git a/apps/ignis-server/Dockerfile b/apps/ignis-server/Dockerfile index 123747f..0f516ba 100644 --- a/apps/ignis-server/Dockerfile +++ b/apps/ignis-server/Dockerfile @@ -50,14 +50,9 @@ COPY apps/ignis-server/scripts/ ./apps/ignis-server/scripts/ COPY images/ ./images/ COPY packages/server-core/src/ ./packages/server-core/src/ -# Bridge plugin: manifest + styles for auto-install into vaults; built main.js comes from the build stage. -COPY packages/bridge/manifest.json ./packages/bridge/ -COPY packages/bridge/styles.css ./packages/bridge/ - # Built artifacts from the build stage. COPY --from=build /app/packages/shim/dist/shim-loader.js ./packages/shim/dist/shim-loader.js COPY --from=build /app/packages/ui/dist/ignis-ui.js ./packages/ui/dist/ignis-ui.js -COPY --from=build /app/packages/bridge/main.js ./packages/bridge/main.js COPY --from=build /app/apps/ignis-server/server/plugins/headless-sync/obsidian/dist/ ./apps/ignis-server/server/plugins/headless-sync/obsidian/dist/ RUN chmod +x /app/apps/ignis-server/scripts/entrypoint.sh diff --git a/apps/ignis-server/server/config.js b/apps/ignis-server/server/config.js index 98645f4..b04903b 100644 --- a/apps/ignis-server/server/config.js +++ b/apps/ignis-server/server/config.js @@ -5,8 +5,7 @@ const REPO_ROOT = path.join(__dirname, "..", "..", ".."); // VAULT_ROOT: a directory that contains vault folders. // Each subdirectory is a vault. New vaults are created as new subdirs. -const vaultRoot = - process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults"); +const vaultRoot = process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults"); const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data"); @@ -81,6 +80,12 @@ module.exports = { ? parseInt(process.env.WRITE_COALESCE_MS) : 5000, + wsOrigins: process.env.WS_ORIGINS + ? process.env.WS_ORIGINS.split(",") + .map((s) => s.trim()) + .filter(Boolean) + : null, + demoMode: process.env.DEMO_MODE === "true", demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20, demoVaultsPerSession: parseInt(process.env.DEMO_VAULTS_PER_SESSION) || 3, @@ -88,8 +93,7 @@ module.exports = { parseInt(process.env.DEMO_SESSION_QUOTA_BYTES) || 700 * 1024, demoTimeoutMs: parseInt(process.env.DEMO_TIMEOUT_MS) || 30 * 60 * 1000, demoTemplateDir: - process.env.DEMO_TEMPLATE_DIR || - path.join(__dirname, "demo-template"), + process.env.DEMO_TEMPLATE_DIR || path.join(__dirname, "demo-template"), obsidianAssetsPath: process.env.OBSIDIAN_ASSETS_PATH || @@ -99,6 +103,7 @@ module.exports = { const assetsPath = process.env.OBSIDIAN_ASSETS_PATH || path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"); + q; try { const pkg = JSON.parse( fs.readFileSync(path.join(assetsPath, "package.json"), "utf-8"), diff --git a/apps/ignis-server/server/index.js b/apps/ignis-server/server/index.js index 71313e7..bc40c64 100644 --- a/apps/ignis-server/server/index.js +++ b/apps/ignis-server/server/index.js @@ -195,7 +195,10 @@ const server = app.listen(config.port, async () => { .catch((e) => console.warn("[bootstrap] warm-up error:", e.message)); }); -const wss = setupWebSocket(server, { getVaultPath: config.getVaultPath }); +const wss = setupWebSocket(server, { + getVaultPath: config.getVaultPath, + originAllowlist: config.wsOrigins, +}); wireDemoWebSocket(server); async function gracefulShutdown(signal) { diff --git a/apps/ignis-server/server/plugin-system/manager.js b/apps/ignis-server/server/plugin-system/manager.js index ec5bd3d..3a6cd18 100644 --- a/apps/ignis-server/server/plugin-system/manager.js +++ b/apps/ignis-server/server/plugin-system/manager.js @@ -3,6 +3,7 @@ const path = require("path"); const express = require("express"); const { discoverPlugins } = require("./discovery"); const configStore = require("./config-store"); +const { getVersion } = require("../version"); let discoveredPlugins = new Map(); const loadedPlugins = new Map(); @@ -171,6 +172,23 @@ async function enablePluginForVault(pluginId, vaultId) { if (loaded?.module?.onVaultEnabled) { await loaded.module.onVaultEnabled(vaultId, vaultPath); } + + // Broadcast to any open tabs on this vault so they load the plugin properly. + if (discovered.obsidianPlugin && discovered.bundledPluginId) { + const v = `?v=${getVersion()}`; + const entry = { + id: discovered.bundledPluginId, + scriptUrl: `/${discovered.bundledPluginId}.js${v}`, + cssUrl: `/${discovered.bundledPluginId}.css${v}`, + manifest: discovered.bundledManifest, + }; + + serverCtx.wss?.broadcastToVault?.(vaultId, { + type: "virtual-plugin-enable", + vault: vaultId, + entry, + }); + } } async function disablePluginForVault(pluginId, vaultId) { @@ -200,6 +218,14 @@ async function disablePluginForVault(pluginId, vaultId) { if (updated.length === 0) { await unloadPlugin(pluginId); } + + if (discovered.bundledPluginId) { + serverCtx.wss?.broadcastToVault?.(vaultId, { + type: "virtual-plugin-disable", + vault: vaultId, + id: discovered.bundledPluginId, + }); + } } function getBundledPluginDirs() { diff --git a/apps/ignis-server/server/plugins/headless-sync/broadcaster.js b/apps/ignis-server/server/plugins/headless-sync/broadcaster.js index de2e307..bfb8b63 100644 --- a/apps/ignis-server/server/plugins/headless-sync/broadcaster.js +++ b/apps/ignis-server/server/plugins/headless-sync/broadcaster.js @@ -2,57 +2,26 @@ const CHANNEL = "plugin:headless-sync"; class SyncBroadcaster { constructor(wss) { - this._wss = wss; - this._logSubscriptions = new Map(); - } - - subscribeToLogs(vaultId) { - this._logSubscriptions.set(vaultId, { expires: Date.now() + 10000 }); + this._channel = wss.channel(CHANNEL); } broadcastLog(vaultId, line) { - if (!this._wss?.clients) { - return; - } - - const sub = this._logSubscriptions.get(vaultId); - - if (!sub || Date.now() > sub.expires) { - return; - } - - this._send({ - channel: CHANNEL, + this._channel.broadcastToVault(vaultId, { type: "sync-log", payload: { vaultId, line }, }); } broadcastStatus(state) { - if (!state) { + if (!state || !state.vaultId) { return; } - this._send({ - channel: CHANNEL, + this._channel.broadcastToVault(state.vaultId, { type: "sync-status", payload: state, }); } - - _send(msg) { - if (!this._wss?.clients) { - return; - } - - const data = JSON.stringify(msg); - - for (const client of this._wss.clients) { - if (client.readyState === 1) { - client.send(data); - } - } - } } module.exports = { SyncBroadcaster }; diff --git a/apps/ignis-server/server/plugins/headless-sync/index.js b/apps/ignis-server/server/plugins/headless-sync/index.js index 9141cd4..fff96bd 100644 --- a/apps/ignis-server/server/plugins/headless-sync/index.js +++ b/apps/ignis-server/server/plugins/headless-sync/index.js @@ -63,22 +63,9 @@ module.exports = { const { mountRoutes } = require("./routes"); mountRoutes(ctx.router, this); - - // Register WebSocket message handler for log subscriptions - if (ctx.wss && ctx.wss.messageHandlers) { - ctx.wss.messageHandlers.set("subscribe-logs", (msg) => { - if (msg.vaultId && this._broadcaster) { - this._broadcaster.subscribeToLogs(msg.vaultId); - } - }); - } }, async shutdown() { - if (this._ctx?.wss?.messageHandlers) { - this._ctx.wss.messageHandlers.delete("subscribe-logs"); - } - if (this._syncManager) { await this._syncManager.shutdown(); this._syncManager = null; diff --git a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/core-sync-guard.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/core-sync-guard.js index 1abf964..fd5942f 100644 --- a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/core-sync-guard.js +++ b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/core-sync-guard.js @@ -32,13 +32,12 @@ function showConflictWarning(title, message) { }); } -function startCoreSyncGuard(plugin, api, wsListener) { +function startCoreSyncGuard(plugin, api) { const app = plugin.app; const vaultId = app.vault.getName(); - // Monkey-patch syncPlugin.enable() to clear the shim flag before - // Obsidian writes core-plugins.json. This ensures the read transform - // doesn't block a user-initiated core sync enable. + // Monkey-patch syncPlugin.enable() to clear the shim flag before Obsidian writes core-plugins.json. + // This ensures the read transform doesn't block a user-initiated core sync enable. const syncPlugin = app.internalPlugins.getPluginById("sync"); let origEnable = null; @@ -52,16 +51,13 @@ function startCoreSyncGuard(plugin, api, wsListener) { }; } - // Watch for core-plugins.json changes via WebSocket. let wasEnabled = isCoreSyncEnabled(); - const rawHandler = (msg) => { - if (msg.type === "modified" && msg.path === CORE_PLUGINS_PATH) { + const unsubModified = window.__ignis.ws.subscribe("modified", (msg) => { + if (msg.path === CORE_PLUGINS_PATH) { handleCoreSyncChange(); } - }; - - wsListener.onRaw(rawHandler); + }); function handleCoreSyncChange() { const enabled = isCoreSyncEnabled(); @@ -80,7 +76,7 @@ function startCoreSyncGuard(plugin, api, wsListener) { return { cleanup() { - wsListener.offRaw(); + unsubModified(); if (syncPlugin && origEnable) { syncPlugin.enable = origEnable; diff --git a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/log-viewer.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/log-viewer.js index 45373f7..43c026d 100644 --- a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/log-viewer.js +++ b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/log-viewer.js @@ -1,6 +1,8 @@ const api = require("./api"); -async function renderLogViewer(containerEl, vaultId, wsListener) { +const CHANNEL = "plugin:headless-sync"; + +async function renderLogViewer(containerEl, vaultId) { const details = containerEl.createEl("details", { cls: "ignis-log-details", }); @@ -32,19 +34,12 @@ async function renderLogViewer(containerEl, vaultId, wsListener) { logBox.scrollTop = logBox.scrollHeight; - if (!wsListener) { - return () => {}; - } + const channel = window.__ignis.ws.channel(CHANNEL); + let unsubLog = null; - details.addEventListener("toggle", () => { - if (details.open) { - wsListener.subscribeLogs(vaultId); - } else { - wsListener.unsubscribeLogs(); - } - }); + const onLog = (msg) => { + const payload = msg.payload || {}; - const onLog = (payload) => { if (payload.vaultId !== vaultId) { return; } @@ -66,11 +61,22 @@ async function renderLogViewer(containerEl, vaultId, wsListener) { } }; - wsListener.on("sync-log", onLog); + details.addEventListener("toggle", () => { + if (details.open) { + if (!unsubLog) { + unsubLog = channel.subscribe("sync-log", onLog); + } + } else if (unsubLog) { + unsubLog(); + unsubLog = null; + } + }); return () => { - wsListener.off("sync-log", onLog); - wsListener.unsubscribeLogs(); + if (unsubLog) { + unsubLog(); + unsubLog = null; + } }; } diff --git a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/main.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/main.js index 1f47399..ed9dbe9 100644 --- a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/main.js +++ b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/main.js @@ -1,6 +1,5 @@ const { Plugin } = require("obsidian"); const { HeadlessSyncSettingTab } = require("./settings-tab"); -const { WsListener } = require("./ws-listener"); const { initSyncStatusBar } = require("./sync-status-bar"); const { startCoreSyncGuard } = require("./core-sync-guard"); const api = require("./api"); @@ -14,14 +13,11 @@ class IgnisHeadlessSyncPlugin extends Plugin { return; } - this.wsListener = new WsListener(); - this.wsListener.start(); - - this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener); + this._syncStatusBarCleanup = initSyncStatusBar(this); this.addSettingTab(new HeadlessSyncSettingTab(this.app, this)); - this._coreSyncGuard = startCoreSyncGuard(this, api, this.wsListener); + this._coreSyncGuard = startCoreSyncGuard(this, api); this.addCommand({ id: "start-sync", @@ -75,11 +71,6 @@ class IgnisHeadlessSyncPlugin extends Plugin { this._syncStatusBarCleanup(); this._syncStatusBarCleanup = null; } - - if (this.wsListener) { - this.wsListener.stop(); - this.wsListener = null; - } } } diff --git a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/settings-tab.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/settings-tab.js index c6d0f96..8c90bd2 100644 --- a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/settings-tab.js +++ b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/settings-tab.js @@ -316,11 +316,7 @@ class HeadlessSyncSettingTab extends PluginSettingTab { } async renderLogs(containerEl, vaultId) { - this._logCleanup = await renderLogViewer( - containerEl, - vaultId, - this.plugin.wsListener, - ); + this._logCleanup = await renderLogViewer(containerEl, vaultId); } hide() { diff --git a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/sync-status-bar.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/sync-status-bar.js index 2c300c6..9503b57 100644 --- a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/sync-status-bar.js +++ b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/sync-status-bar.js @@ -1,6 +1,8 @@ const { setIcon } = require("obsidian"); const api = require("./api"); +const CHANNEL = "plugin:headless-sync"; + const TOOLTIP_MAP = { running: "Syncing...", synced: "Synced", @@ -8,8 +10,11 @@ const TOOLTIP_MAP = { error: "Sync error", }; -function initSyncStatusBar(plugin, wsListener) { +function initSyncStatusBar(plugin) { const vaultId = plugin.app.vault.getName(); + const ws = window.__ignis.ws; + const channel = ws.channel(CHANNEL); + const item = plugin.addStatusBarItem(); item.addClass("ignis-sync-statusbar"); item.style.display = "none"; @@ -21,6 +26,7 @@ function initSyncStatusBar(plugin, wsListener) { let popoverOpen = false; let currentStatus = "stopped"; let outsideClickHandler = null; + let unsubLog = null; function updateState(status, error) { currentStatus = status; @@ -62,7 +68,7 @@ function initSyncStatusBar(plugin, wsListener) { popoverOpen = true; - wsListener.subscribeLogs(vaultId); + unsubLog = channel.subscribe("sync-log", onLog); outsideClickHandler = (e) => { if (!item.contains(e.target)) { @@ -86,7 +92,11 @@ function initSyncStatusBar(plugin, wsListener) { outsideClickHandler = null; } - wsListener.unsubscribeLogs(); + if (unsubLog) { + unsubLog(); + unsubLog = null; + } + popoverOpen = false; } @@ -95,7 +105,7 @@ function initSyncStatusBar(plugin, wsListener) { return path; } - return "\u2026" + path.slice(-(maxLen - 1)); + return "…" + path.slice(-(maxLen - 1)); } function formatPopoverText(prefix, path) { @@ -115,35 +125,30 @@ function initSyncStatusBar(plugin, wsListener) { } function extractFileActivity(line) { - // Downloading/Downloaded path let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/); if (match) { return { prefix: "Syncing", path: match[1].trim() }; } - // Uploading file / Upload complete path match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/); if (match) { return { prefix: "Syncing", path: match[1].trim() }; } - // Deleting path match = line.match(/^Deleting\s+(.+)$/); if (match) { return { prefix: "Deleting", path: match[1].trim() }; } - // Push: path (updated) match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/); if (match) { return { prefix: "Syncing", path: match[1].trim() }; } - // Push: path (deleted) match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/); if (match) { @@ -157,7 +162,6 @@ function initSyncStatusBar(plugin, wsListener) { return /Fully synced/i.test(line); } - // Click toggles popover item.addEventListener("click", () => { if (popoverOpen) { hidePopover(); @@ -166,16 +170,15 @@ function initSyncStatusBar(plugin, wsListener) { } }); - // Listen for status updates - const onStatus = (payload) => { + const onStatus = (msg) => { + const payload = msg.payload || {}; + if (payload.vaultId !== vaultId) { return; } item.style.display = ""; - // "running" from server means the process is alive, but we refine - // the visual state based on log activity. if (payload.status === "running") { updateState("synced"); } else { @@ -183,10 +186,8 @@ function initSyncStatusBar(plugin, wsListener) { } }; - wsListener.on("sync-status", onStatus); + const unsubStatus = channel.subscribe("sync-status", onStatus); - // Debounce the transition to "synced" state to avoid flickering - // during rapid delete cycles (Fully synced -> Deleting -> Fully synced). let syncedTimer = null; function deferSynced() { @@ -208,8 +209,9 @@ function initSyncStatusBar(plugin, wsListener) { } } - // Listen for log lines - const onLog = (payload) => { + function onLog(msg) { + const payload = msg.payload || {}; + if (payload.vaultId !== vaultId) { return; } @@ -226,11 +228,8 @@ function initSyncStatusBar(plugin, wsListener) { updateState("running"); updatePopoverText(formatPopoverText(activity.prefix, activity.path)); } - }; + } - wsListener.on("sync-log", onLog); - - // Fetch initial state api .getVaults() .then((data) => { @@ -244,16 +243,16 @@ function initSyncStatusBar(plugin, wsListener) { }) .catch(() => {}); - // Poll WebSocket state to detect server disconnect/reconnect + // Reflect WebSocket disconnect/reconnect in the indicator. let wasDisconnected = false; - const wsCheckInterval = setInterval(() => { - const disconnected = !wsListener.isConnected(); + const unsubState = ws.onStateChange((state) => { + const open = state === "open"; - if (disconnected && currentStatus === "running") { + if (!open && currentStatus === "running") { updateState("error", "Server connection lost"); wasDisconnected = true; - } else if (!disconnected && wasDisconnected) { + } else if (open && wasDisconnected) { wasDisconnected = false; api @@ -268,14 +267,12 @@ function initSyncStatusBar(plugin, wsListener) { }) .catch(() => {}); } - }, 3000); + }); - // Return cleanup function return () => { - clearInterval(wsCheckInterval); cancelDeferredSynced(); - wsListener.off("sync-status", onStatus); - wsListener.off("sync-log", onLog); + unsubStatus(); + unsubState(); hidePopover(); }; } diff --git a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/ws-listener.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/ws-listener.js deleted file mode 100644 index 2ed0264..0000000 --- a/apps/ignis-server/server/plugins/headless-sync/obsidian/src/ws-listener.js +++ /dev/null @@ -1,153 +0,0 @@ -const CHANNEL = "plugin:headless-sync"; -const POLL_INTERVAL = 3000; -const LOG_KEEPALIVE_INTERVAL = 7000; - -class WsListener { - constructor() { - this._callbacks = new Map(); - this._handler = null; - this._rawHandler = null; - this._pollTimer = null; - this._currentWs = null; - this._logSubInterval = null; - this._logSubVaultId = null; - } - - start() { - this._attachToWs(); - - this._pollTimer = setInterval(() => { - this._attachToWs(); - }, POLL_INTERVAL); - } - - stop() { - if (this._pollTimer) { - clearInterval(this._pollTimer); - this._pollTimer = null; - } - - this.unsubscribeLogs(); - this._detachFromWs(); - } - - isConnected() { - const ws = window.__ignisWs; - return ws && ws.readyState === WebSocket.OPEN; - } - - on(type, callback) { - if (!this._callbacks.has(type)) { - this._callbacks.set(type, []); - } - - this._callbacks.get(type).push(callback); - } - - off(type, callback) { - const list = this._callbacks.get(type); - - if (!list) { - return; - } - - const idx = list.indexOf(callback); - - if (idx !== -1) { - list.splice(idx, 1); - } - } - - // Listen for raw WebSocket messages (not channel-filtered). - // Used by core-sync-guard to watch for file changes. - onRaw(callback) { - this._rawHandler = callback; - } - - offRaw() { - this._rawHandler = null; - } - - send(type, payload) { - const ws = window.__ignisWs; - - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type, ...payload })); - } - } - - // Subscribe to server log broadcasts for a vault. - // Sends the initial subscribe message and keeps the subscription alive. - subscribeLogs(vaultId) { - // If already subscribed to this vault, no-op. - if (this._logSubVaultId === vaultId && this._logSubInterval) { - return; - } - - this.unsubscribeLogs(); - this._logSubVaultId = vaultId; - - this.send("subscribe-logs", { vaultId }); - - this._logSubInterval = setInterval(() => { - this.send("subscribe-logs", { vaultId }); - }, LOG_KEEPALIVE_INTERVAL); - } - - // Stop the log subscription keepalive. - unsubscribeLogs() { - if (this._logSubInterval) { - clearInterval(this._logSubInterval); - this._logSubInterval = null; - } - - this._logSubVaultId = null; - } - - _attachToWs() { - const ws = window.__ignisWs; - - if (!ws || ws === this._currentWs) { - return; - } - - this._detachFromWs(); - this._currentWs = ws; - - this._handler = (event) => { - try { - const msg = JSON.parse(event.data); - - // Dispatch raw messages (for non-channel listeners like file watchers) - if (this._rawHandler) { - this._rawHandler(msg); - } - - if (msg.channel !== CHANNEL) { - return; - } - - const listeners = this._callbacks.get(msg.type); - - if (listeners) { - for (const cb of listeners) { - cb(msg.payload); - } - } - } catch {} - }; - - ws.addEventListener("message", this._handler); - } - - _detachFromWs() { - if (this._currentWs && this._handler) { - this._currentWs.removeEventListener("message", this._handler); - } - - this._currentWs = null; - this._handler = null; - } -} - -module.exports = { WsListener }; diff --git a/packages/bridge/src/settings/general-tab.js b/packages/bridge/src/settings/general-tab.js index b18f1a6..bd50b9c 100644 --- a/packages/bridge/src/settings/general-tab.js +++ b/packages/bridge/src/settings/general-tab.js @@ -95,64 +95,44 @@ function display(containerEl, app) { addServerStatus(containerEl); } -function getWsStatus() { - const ws = window.__ignisWs; +const STATUS_LABELS = { + open: "Connected", + connecting: "Connecting...", + closed: "Disconnected", +}; - if (!ws) { - return "disconnected"; - } - - switch (ws.readyState) { - case WebSocket.CONNECTING: - return "connecting"; - case WebSocket.OPEN: - return "connected"; - case WebSocket.CLOSING: - case WebSocket.CLOSED: - return "disconnected"; - default: - return "disconnected"; - } -} - -function statusLabel(status) { - switch (status) { - case "connected": - return "Connected"; - case "connecting": - return "Connecting..."; - case "disconnected": - return "Disconnected"; - default: - return "Unknown"; - } -} +const STATUS_DOT_CLASSES = { + open: "ignis-status-connected", + connecting: "ignis-status-connecting", + closed: "ignis-status-disconnected", +}; function addServerStatus(containerEl) { - const status = getWsStatus(); + const ws = window.__ignis.ws; const setting = new Setting(containerEl).setName("Server status"); const dotEl = setting.controlEl.createEl("span", { - cls: `ignis-status-dot ignis-status-${status}`, + cls: "ignis-status-dot", }); const labelEl = setting.controlEl.createEl("span", { - text: statusLabel(status), cls: "ignis-status-label", }); - const update = () => { - const s = getWsStatus(); - dotEl.className = `ignis-status-dot ignis-status-${s}`; - labelEl.textContent = statusLabel(s); - }; + function render(state) { + dotEl.className = `ignis-status-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`; + labelEl.textContent = STATUS_LABELS[state] || STATUS_LABELS.closed; + } - const pollInterval = setInterval(update, 3000); + render(ws.isOpen() ? "open" : "closed"); + const unsub = ws.onStateChange(render); + + // Detach when the settings tab DOM goes away. const observer = new MutationObserver(() => { if (!containerEl.isConnected) { - clearInterval(pollInterval); + unsub(); observer.disconnect(); } }); diff --git a/packages/bridge/src/settings/inject.js b/packages/bridge/src/settings/inject.js index 9e0cb2d..427c0e9 100644 --- a/packages/bridge/src/settings/inject.js +++ b/packages/bridge/src/settings/inject.js @@ -2,6 +2,7 @@ const generalTab = require("./general-tab"); const serverPluginsTab = require("./server-plugins-tab"); const { createNavEl, createTab, createGroup } = require("./settings-ui"); const { + allIgnisNavEls, setupPluginTabs, reconcilePluginTabs, hideIgnisFromCommunityPlugins, @@ -24,10 +25,6 @@ function removeExistingIgnisGroups(tabHeadersEl) { } } -// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group). -// Collected here so the openTab patch can manage is-active across all of them. -const allIgnisNavEls = new Map(); // tab id -> nav element - function replaceInstallerVersionRow(setting, ignisVersion) { const container = setting.tabContentContainer || setting.contentEl; @@ -117,7 +114,7 @@ function injectIgnisSettings(setting, app, plugin) { setting.tabHeadersEl.appendChild(corePlugins.group); hideIgnisFromCommunityPlugins(setting); - setupPluginTabs(setting, corePlugins.items, allIgnisNavEls); + setupPluginTabs(setting, corePlugins.items); } function patchSettingsModal(plugin) { @@ -142,7 +139,4 @@ function unpatchSettingsModal(plugin) { clearOwnedPluginIds(); } -window.__ignisReconcilePluginTabs = (setting) => - reconcilePluginTabs(setting, allIgnisNavEls); - module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs }; diff --git a/packages/bridge/src/settings/plugin-tabs.js b/packages/bridge/src/settings/plugin-tabs.js index 1d7bea7..a86da07 100644 --- a/packages/bridge/src/settings/plugin-tabs.js +++ b/packages/bridge/src/settings/plugin-tabs.js @@ -2,10 +2,14 @@ const { setIcon } = require("obsidian"); const { findGroupByTitle } = require("./settings-ui"); const { isIgnisPlugin } = require("../plugin-registry"); +// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group). +// Shared with inject.js so the openTab patch can manage is-active across all of them. +const allIgnisNavEls = new Map(); // tab id -> nav element + // Tracks which plugin IDs have nav items we created. const ownedPluginIds = new Set(); -function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) { +function addPluginNavItem(pluginId, setting, corePluginsItems) { const tab = setting.pluginTabs.find((t) => t.id === pluginId); if (!tab) { @@ -41,16 +45,16 @@ function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) { corePluginsItems.appendChild(nav); ownedPluginIds.add(pluginId); - ignisNavEls.set(pluginId, nav); + allIgnisNavEls.set(pluginId, nav); } -function removePluginNavItem(pluginId, ignisNavEls) { - const nav = ignisNavEls.get(pluginId); +function removePluginNavItem(pluginId) { + const nav = allIgnisNavEls.get(pluginId); if (nav && ownedPluginIds.has(pluginId)) { nav.remove(); ownedPluginIds.delete(pluginId); - ignisNavEls.delete(pluginId); + allIgnisNavEls.delete(pluginId); } } @@ -116,11 +120,11 @@ function hideIgnisNavFromCommunityGroup(setting) { communityGroup.style.display = hasVisible ? "" : "none"; } -function hideCorePluginsGroupIfEmpty(ignisNavEls) { +function hideCorePluginsGroupIfEmpty() { let hasConnected = false; for (const id of ownedPluginIds) { - const nav = ignisNavEls.get(id); + const nav = allIgnisNavEls.get(id); if (nav?.isConnected) { hasConnected = true; @@ -140,15 +144,15 @@ function hideCorePluginsGroupIfEmpty(ignisNavEls) { } } -function setupPluginTabs(setting, corePluginsItems, ignisNavEls) { +function setupPluginTabs(setting, corePluginsItems) { for (const tab of setting.pluginTabs) { if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") { - addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls); + addPluginNavItem(tab.id, setting, corePluginsItems); } } hideIgnisNavFromCommunityGroup(setting); - hideCorePluginsGroupIfEmpty(ignisNavEls); + hideCorePluginsGroupIfEmpty(); const communityGroup = findGroupByTitle( setting.tabHeadersEl, @@ -159,12 +163,12 @@ function setupPluginTabs(setting, corePluginsItems, ignisNavEls) { const observer = new MutationObserver(() => { for (const tab of setting.pluginTabs) { if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") { - addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls); + addPluginNavItem(tab.id, setting, corePluginsItems); } } hideIgnisNavFromCommunityGroup(setting); - hideCorePluginsGroupIfEmpty(ignisNavEls); + hideCorePluginsGroupIfEmpty(); }); observer.observe(communityGroup, { childList: true, subtree: true }); @@ -186,7 +190,7 @@ function setupPluginTabs(setting, corePluginsItems, ignisNavEls) { } } -function reconcilePluginTabs(setting, ignisNavEls) { +function reconcilePluginTabs(setting) { const corePluginsGroup = findGroupByTitle( setting.tabHeadersEl, "Ignis Core Plugins", @@ -212,16 +216,16 @@ function reconcilePluginTabs(setting, ignisNavEls) { for (const id of ownedPluginIds) { if (!activeIds.has(id)) { - removePluginNavItem(id, ignisNavEls); + removePluginNavItem(id); } } for (const id of activeIds) { - addPluginNavItem(id, setting, corePluginsItems, ignisNavEls); + addPluginNavItem(id, setting, corePluginsItems); } hideIgnisNavFromCommunityGroup(setting); - hideCorePluginsGroupIfEmpty(ignisNavEls); + hideCorePluginsGroupIfEmpty(); } function clearOwnedPluginIds() { @@ -229,6 +233,7 @@ function clearOwnedPluginIds() { } module.exports = { + allIgnisNavEls, setupPluginTabs, reconcilePluginTabs, hideIgnisFromCommunityPlugins, diff --git a/packages/bridge/src/settings/server-plugins-tab.js b/packages/bridge/src/settings/server-plugins-tab.js index dbb940e..bff53e4 100644 --- a/packages/bridge/src/settings/server-plugins-tab.js +++ b/packages/bridge/src/settings/server-plugins-tab.js @@ -1,18 +1,10 @@ const { Setting, Notice } = require("obsidian"); +const { reconcilePluginTabs } = require("./plugin-tabs"); function getVaultId() { return window.__currentVaultId || ""; } -async function refreshPluginCache(bundledPluginId) { - const pluginPath = `.obsidian/plugins/${bundledPluginId}`; - const fs = require("fs"); - - if (fs._refreshSubtree) { - await fs._refreshSubtree(pluginPath); - } -} - async function fetchPlugins() { const res = await fetch("/api/plugins"); @@ -23,7 +15,7 @@ async function fetchPlugins() { return res.json(); } -async function togglePlugin(pluginId, enable, app) { +async function togglePlugin(pluginId, enable) { const action = enable ? "enable" : "disable"; const vaultId = getVaultId(); @@ -41,25 +33,10 @@ async function togglePlugin(pluginId, enable, app) { return res.json(); } -async function activateBundledPlugin(bundledPluginId, enable, app) { - if (!bundledPluginId) { - return; - } - - const plugins = app.plugins; - - if (enable) { - await plugins.loadManifests(); - await plugins.enablePluginAndSave(bundledPluginId); - } else { - await plugins.disablePluginAndSave(bundledPluginId); - } -} - function display(containerEl, app) { containerEl.createEl("h2", { text: "Ignis Core Plugins" }); - const descEl = containerEl.createEl("p", { + containerEl.createEl("p", { text: "Ignis plugins extend server functionality and run alongside your vaults. " + "They are separate from Obsidian's built-in plugins.", @@ -92,28 +69,16 @@ function display(containerEl, app) { toggle.setValue(enabled); toggle.onChange(async (value) => { try { - await togglePlugin(plugin.id, value, app); - - if (value && plugin.bundledPluginId) { - await refreshPluginCache(plugin.bundledPluginId); - } - - await activateBundledPlugin( - plugin.bundledPluginId, - value, - app, - ); + await togglePlugin(plugin.id, value); new Notice( `${plugin.name} ${value ? "enabled" : "disabled"} for this vault.`, ); - // Give Obsidian a moment to update its plugin tabs, - // then reconcile our sidebar groups. + // The server's WS broadcast drives the actual load/unload via virtual-plugin-loader. + // Reconcile the settings sidebar so the new plugin's settings tab gets grouped correctly. setTimeout(() => { - if (typeof window.__ignisReconcilePluginTabs === "function") { - window.__ignisReconcilePluginTabs(app.setting); - } + reconcilePluginTabs(app.setting); }, 100); } catch (e) { new Notice(`Failed: ${e.message}`); diff --git a/packages/bridge/src/status-bar.js b/packages/bridge/src/status-bar.js index f93b387..e3f882d 100644 --- a/packages/bridge/src/status-bar.js +++ b/packages/bridge/src/status-bar.js @@ -1,27 +1,18 @@ -function getWsStatus() { - const ws = window.__ignisWs; - - if (!ws) { - return "disconnected"; - } - - switch (ws.readyState) { - case WebSocket.CONNECTING: - return "connecting"; - case WebSocket.OPEN: - return "connected"; - default: - return "disconnected"; - } -} - const STATUS_LABELS = { - connected: "Ignis server: Connected", + open: "Ignis server: Connected", connecting: "Ignis server: Connecting...", - disconnected: "Ignis server: Disconnected", + closed: "Ignis server: Disconnected", +}; + +const STATUS_DOT_CLASSES = { + open: "ignis-statusbar-connected", + connecting: "ignis-statusbar-connecting", + closed: "ignis-statusbar-disconnected", }; function initStatusBar(plugin) { + const ws = window.__ignis.ws; + const item = plugin.addStatusBarItem(); item.addClass("ignis-statusbar-item"); @@ -29,20 +20,16 @@ function initStatusBar(plugin) { cls: "ignis-statusbar-dot", }); - item.setAttribute("aria-label", "Ignis: Checking..."); item.setAttribute("data-tooltip-position", "top"); - const update = () => { - const status = getWsStatus(); - dot.className = `ignis-statusbar-dot ignis-statusbar-${status}`; - item.setAttribute("aria-label", STATUS_LABELS[status] || "Ignis: Unknown"); - }; + function render(state) { + dot.className = `ignis-statusbar-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`; + item.setAttribute("aria-label", STATUS_LABELS[state] || STATUS_LABELS.closed); + } - update(); + render(ws.isOpen() ? "open" : "closed"); - const interval = setInterval(update, 3000); - - return interval; + return ws.onStateChange(render); } module.exports = { initStatusBar }; diff --git a/packages/server-core/src/ws.js b/packages/server-core/src/ws.js index ca05132..9b42e46 100644 --- a/packages/server-core/src/ws.js +++ b/packages/server-core/src/ws.js @@ -3,18 +3,117 @@ const url = require("url"); const watcher = require("./watcher"); function setupWebSocket(server, opts = {}) { - const { getVaultPath } = opts; + const { getVaultPath, originAllowlist } = opts; if (typeof getVaultPath !== "function") { throw new Error("setupWebSocket: opts.getVaultPath is required"); } + // Null / undefined / empty array = no Origin check. + const originSet = + Array.isArray(originAllowlist) && originAllowlist.length > 0 + ? new Set(originAllowlist) + : null; + const wss = new WebSocketServer({ server, path: "/ws" }); - // Plugin-registered message handlers: type -> handler(msg, ws) + // Global message handlers: type -> handler(msg, ws). wss.messageHandlers = new Map(); + // Channel-scoped message handlers: channel -> Map. + const channelHandlers = new Map(); + + // Connected clients per vault, for outbound broadcasts. + const clientsByVault = new Map(); + + // Per-client channel subscriptions, populated by inbound subscribe-channel / unsubscribe-channel messages. + // The broadcast layer uses this to gate channel-scoped broadcasts to only the clients that asked for them. + const channelSubsByClient = new WeakMap(); + + function clientHasChannel(ws, channelName) { + return channelSubsByClient.get(ws)?.has(channelName) === true; + } + + function addClientChannel(ws, channelName) { + let set = channelSubsByClient.get(ws); + + if (!set) { + set = new Set(); + channelSubsByClient.set(ws, set); + } + + set.add(channelName); + } + + function removeClientChannel(ws, channelName) { + channelSubsByClient.get(ws)?.delete(channelName); + } + + wss.broadcastToVault = function (vaultId, message) { + const clients = clientsByVault.get(vaultId); + + if (!clients) { + return; + } + + const payload = JSON.stringify(message); + + for (const ws of clients) { + if (ws.readyState === ws.OPEN) { + ws.send(payload); + } + } + }; + + wss.channel = function (name) { + return { + on(type, handler) { + if (!channelHandlers.has(name)) { + channelHandlers.set(name, new Map()); + } + + channelHandlers.get(name).set(type, handler); + }, + + off(type) { + channelHandlers.get(name)?.delete(type); + }, + + // Sends a channel-scoped message only to clients that subscribed to this channel via subscribe-channel. + broadcastToVault(vaultId, message) { + const clients = clientsByVault.get(vaultId); + + if (!clients) { + return; + } + + const payload = JSON.stringify({ channel: name, ...message }); + + for (const ws of clients) { + if (ws.readyState !== ws.OPEN) { + continue; + } + + if (!clientHasChannel(ws, name)) { + continue; + } + + ws.send(payload); + } + }, + }; + }; + wss.on("connection", (ws, req) => { + if (originSet) { + const origin = req.headers.origin; + + if (!origin || !originSet.has(origin)) { + ws.close(4003, "Origin not allowed"); + return; + } + } + const params = new url.URL(req.url, "http://localhost").searchParams; const vaultId = params.get("vault"); @@ -26,10 +125,16 @@ function setupWebSocket(server, opts = {}) { const vaultPath = getVaultPath(vaultId); console.log(`[ws] Client connected to vault: ${vaultId}`); + if (!clientsByVault.has(vaultId)) { + clientsByVault.set(vaultId, new Set()); + } + + clientsByVault.get(vaultId).add(ws); + // Start watching this vault (no-op if already watching) watcher.startWatching(vaultId, vaultPath); - // Per-client listener that forwards events over WebSocket + // Per-client listener that forwards file events over WebSocket const listener = (event) => { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify(event)); @@ -38,21 +143,68 @@ function setupWebSocket(server, opts = {}) { watcher.addListener(vaultId, listener); - // Dispatch incoming messages to registered handlers + // Dispatch incoming messages to registered handlers. ws.on("message", (data) => { - try { - const msg = JSON.parse(data); - const handler = wss.messageHandlers.get(msg.type); + let msg; - if (handler) { - handler(msg, ws); + try { + msg = JSON.parse(data); + } catch (e) { + console.warn("[ws] failed to parse incoming message:", e.message); + return; + } + + // Built-in channel-subscription tracking. Plugins don't register handlers for these types. + if (msg.type === "subscribe-channel" && typeof msg.channel === "string") { + addClientChannel(ws, msg.channel); + return; + } + + if ( + msg.type === "unsubscribe-channel" && + typeof msg.channel === "string" + ) { + removeClientChannel(ws, msg.channel); + return; + } + + try { + if (msg.channel) { + const handler = channelHandlers.get(msg.channel)?.get(msg.type); + + if (handler) { + handler(msg, ws); + } + } else { + const handler = wss.messageHandlers.get(msg.type); + + if (handler) { + handler(msg, ws); + } } - } catch {} + } catch (e) { + console.warn( + `[ws] handler for ${msg.channel ? msg.channel + ":" : ""}${msg.type} threw:`, + e.message, + ); + } }); ws.on("close", () => { console.log(`[ws] Client disconnected from vault: ${vaultId}`); watcher.removeListener(vaultId, listener); + + const set = clientsByVault.get(vaultId); + + if (set) { + set.delete(ws); + + if (set.size === 0) { + clientsByVault.delete(vaultId); + } + } + + channelSubsByClient.delete(ws); }); }); diff --git a/packages/shim/src/fs/index.js b/packages/shim/src/fs/index.js index 9f37a43..fdfaa3e 100644 --- a/packages/shim/src/fs/index.js +++ b/packages/shim/src/fs/index.js @@ -8,6 +8,7 @@ import { createWatcherClient } from "./watcher-client.js"; import { createFdOps } from "./fd.js"; import { constants } from "./constants.js"; import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js"; +import { wsClient } from "../ws-client.js"; const metadataCache = new MetadataCache(); const contentCache = new ContentCache(); @@ -15,7 +16,7 @@ const contentCache = new ContentCache(); const fsPromises = createFsPromises(metadataCache, contentCache, transport); const fsSync = createFsSync(metadataCache, contentCache, transport); const fsWatch = createFsWatch(transport); -const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch); +const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient); const fdOps = createFdOps(metadataCache, contentCache, transport); export const fsShim = { diff --git a/packages/shim/src/fs/watcher-client.js b/packages/shim/src/fs/watcher-client.js index 3a68415..ed64283 100644 --- a/packages/shim/src/fs/watcher-client.js +++ b/packages/shim/src/fs/watcher-client.js @@ -1,143 +1,83 @@ -// Client-side WebSocket file watcher. -// Connects to the server's /ws endpoint, receives file change events, -// updates the metadata/content caches, and dispatches to fs.watch listeners -// so Obsidian's vault picks them up automatically. +// Bridges WebSocket file events to the fs shim's metadata/content caches and fs.watch listeners. +// The WebSocket itself is owned by ws-client.js; this module is a consumer. import { isRecentLocalOp } from "./echo-guard.js"; -const RECONNECT_DELAY = 2000; +export function createWatcherClient(metadataCache, contentCache, fsWatch, wsClient) { + function handleCreated(msg) { + const { path, stat } = msg; -export function createWatcherClient(metadataCache, contentCache, fsWatch) { - let ws = null; - let vaultId = null; - let reconnectTimer = null; - - function connect(vault) { - vaultId = vault; - - if (!vaultId) { - console.warn("[watcher] No vault ID, skipping WebSocket connection"); + if (!path || isRecentLocalOp(path)) { return; } - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const url = `${protocol}//${window.location.host}/ws?vault=${encodeURIComponent(vaultId)}`; - - try { - ws = new WebSocket(url); - window.__ignisWs = ws; - } catch (e) { - console.error("[watcher] Failed to create WebSocket:", e); - scheduleReconnect(); - return; + if (stat) { + metadataCache.set(path, { + type: "file", + size: stat.size, + mtime: stat.mtime, + ctime: stat.ctime, + }); } - ws.onopen = () => { - console.log("[watcher] Connected to file watcher"); - }; - - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data); - handleEvent(msg); - } catch (e) { - console.error("[watcher] Failed to parse message:", e); - } - }; - - ws.onclose = () => { - console.log("[watcher] Disconnected"); - ws = null; - scheduleReconnect(); - }; - - ws.onerror = (e) => { - console.error("[watcher] WebSocket error:", e); - }; + contentCache.invalidate(path); + fsWatch._dispatch("created", path); } - function scheduleReconnect() { - if (reconnectTimer) return; + function handleFolderCreated(msg) { + const { path } = msg; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; + if (!path || isRecentLocalOp(path)) { + return; + } - if (vaultId) { - console.log("[watcher] Reconnecting..."); - connect(vaultId); - } - }, RECONNECT_DELAY); + metadataCache.set(path, { type: "directory" }); + fsWatch._dispatch("folder-created", path); } - function handleEvent(msg) { - // Skip channel-based plugin messages, those are for other listeners - if (msg.channel) { + function handleModified(msg) { + const { path, stat } = msg; + + if (!path || isRecentLocalOp(path)) { return; } - const { type, path, stat } = msg; + if (stat) { + metadataCache.set(path, { + type: "file", + size: stat.size, + mtime: stat.mtime, + ctime: stat.ctime, + }); + } - if (!type || !path) return; + contentCache.invalidate(path); + fsWatch._dispatch("modified", path); + } - // Suppress echo from our own operations - if (isRecentLocalOp(path)) { + function handleDeleted(msg) { + const { path } = msg; + + if (!path || isRecentLocalOp(path)) { return; } - switch (type) { - case "created": - if (stat) { - metadataCache.set(path, { - type: "file", - size: stat.size, - mtime: stat.mtime, - ctime: stat.ctime, - }); - } - contentCache.invalidate(path); - fsWatch._dispatch("created", path); - break; + metadataCache.delete(path); + contentCache.invalidate(path); + fsWatch._dispatch("deleted", path); + } - case "folder-created": - metadataCache.set(path, { type: "directory" }); - fsWatch._dispatch("folder-created", path); - break; + wsClient.subscribe("created", handleCreated); + wsClient.subscribe("folder-created", handleFolderCreated); + wsClient.subscribe("modified", handleModified); + wsClient.subscribe("deleted", handleDeleted); - case "modified": - if (stat) { - metadataCache.set(path, { - type: "file", - size: stat.size, - mtime: stat.mtime, - ctime: stat.ctime, - }); - } - contentCache.invalidate(path); - fsWatch._dispatch("modified", path); - break; - - case "deleted": - metadataCache.delete(path); - contentCache.invalidate(path); - fsWatch._dispatch("deleted", path); - break; - - default: - console.warn("[watcher] Unknown event type:", type); - } + function connect(vaultId) { + wsClient.connect(vaultId); } function disconnect() { - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - - if (ws) { - ws.onclose = null; // prevent reconnect - ws.close(); - ws = null; - } + wsClient.disconnect(); } return { diff --git a/packages/shim/src/ignis-api.js b/packages/shim/src/ignis-api.js new file mode 100644 index 0000000..2263ff0 --- /dev/null +++ b/packages/shim/src/ignis-api.js @@ -0,0 +1,28 @@ +// Public Ignis API surface. The documented way for plugins (and Ignis-internal code) to reach shim services. +// WIP, may expand to cover more shared functionality. + +export function installIgnisApi(wsClient) { + window.__ignis = window.__ignis || {}; + + // Live getters so vault info reflects whatever init.js / vault-switch code has set. + Object.defineProperty(window.__ignis, "vault", { + get() { + return { + id: window.__currentVaultId || null, + path: window.__vaultConfig?.path || null, + }; + }, + enumerable: true, + configurable: true, + }); + + window.__ignis.ws = { + subscribe: wsClient.subscribe, + send: wsClient.send, + channel: wsClient.channel, + isOpen: wsClient.isOpen, + onStateChange: wsClient.onStateChange, + }; + + window.__ignis.plugins = window.__ignis.plugins || {}; +} diff --git a/packages/shim/src/init.js b/packages/shim/src/init.js index 9cd64d9..0a94543 100644 --- a/packages/shim/src/init.js +++ b/packages/shim/src/init.js @@ -1,7 +1,6 @@ import { fsShim } from "./fs/index.js"; import { installRequestUrlShim } from "./request-url.js"; import { vaultService } from "@ignis/services"; -import { showPluginInstallDialog } from "./ui-registry.js"; import { registerReadTransform } from "./fs/transforms.js"; import { resolveWorkspaceName, @@ -12,6 +11,12 @@ import { prefetchVaultContent } from "./fs/indexer-prefetch.js"; import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js"; import { initNativeMenuGuard } from "./native-menu-guard.js"; +let bootstrapVirtualPlugins = []; + +export function getBootstrapVirtualPlugins() { + return bootstrapVirtualPlugins; +} + function resolveVaultId() { const urlParams = new URLSearchParams(window.location.search); window.__currentVaultId = @@ -56,8 +61,6 @@ function applyVaultInfo(info) { path: "/", }; - window.__ignisPlugin = info.ignisPlugin || null; - console.log("[ignis] Vault:", window.__vaultConfig); console.log("[ignis] Obsidian version:", window.__obsidianVersion); } @@ -124,30 +127,6 @@ function initMetadataCacheFallback() { } } -function initPluginPrompt() { - if ( - !window.__ignisPlugin || - window.__ignisPlugin.installed || - window.__ignisPlugin.prompted - ) { - return; - } - - const vaultId = window.__currentVaultId; - - const observer = new MutationObserver(() => { - if (document.querySelector(".workspace")) { - observer.disconnect(); - showPluginInstallDialog(vaultId); - } - }); - - observer.observe(document.documentElement, { - childList: true, - subtree: true, - }); -} - // if headless sync is active, we transform reads of core-plugins.json to hide the sync setting from Obsidian. // this prevents headless sync from being disabled as a result of a different device syncing "Active core plugins list". // i.e ensure Ignis always has sync: false if headless sync is active. @@ -232,7 +211,7 @@ export function initialize() { autoTrustDemoVaults(bootstrap.vaultList); applyTree(bootstrap.tree); applyCoreSyncGuard(bootstrap.plugins); - window.__ignisVirtualPlugins = bootstrap.virtualPlugins || []; + bootstrapVirtualPlugins = bootstrap.virtualPlugins || []; // Race the indexer: batch-fetch text content into ContentCache so // Obsidian's startup indexing reads hit the cache instead of the network. @@ -250,5 +229,4 @@ export function initialize() { installRequestUrlShim(); initWorkspacePatch(); - initPluginPrompt(); } diff --git a/packages/shim/src/loader.js b/packages/shim/src/loader.js index 6b24d22..31a595f 100644 --- a/packages/shim/src/loader.js +++ b/packages/shim/src/loader.js @@ -1,18 +1,24 @@ import { installRequire } from "./require.js"; import { installGlobals } from "./globals.js"; import { installCssOverrides } from "./css-overrides.js"; -import { initialize } from "./init.js"; +import { initialize, getBootstrapVirtualPlugins } from "./init.js"; import { fsShim } from "./fs/index.js"; import { registerUI } from "./ui-registry.js"; import { extractObsidianModule, loadVirtualPlugin, + reportLoadFailure, + watchPluginToggles, } from "./virtual-plugin-loader.js"; +import { wsClient } from "./ws-client.js"; +import { installIgnisApi } from "./ignis-api.js"; // __IGNIS_VERSION__ is replaced at build time from package.json. window.__ignis = { version: __IGNIS_VERSION__ }; window.__ignis_registerUI = registerUI; +installIgnisApi(wsClient); + const BRIDGE_MANIFEST = { id: "ignis-bridge", name: "Ignis Bridge", @@ -38,9 +44,10 @@ if (window.innerWidth < 600) { initialize(); // vault config, metadata cache, plugin prompt -// Connect file watcher WebSocket after everything is initialized +// Connect the shared WebSocket after everything is initialized; watcher and live-toggle subscribers attach to the same client. if (window.__currentVaultId) { fsShim._watcherClient.connect(window.__currentVaultId); + watchPluginToggles(wsClient); } extractObsidianModule() @@ -52,12 +59,12 @@ extractObsidianModule() await bridge.onload(); console.log("[ignis] bridge loaded"); - for (const vp of window.__ignisVirtualPlugins || []) { + for (const vp of getBootstrapVirtualPlugins()) { try { await loadVirtualPlugin(vp); console.log(`[ignis] virtual plugin loaded: ${vp.id}`); } catch (e) { - console.error(`[ignis] virtual plugin load failed: ${vp.id}`, e); + reportLoadFailure(vp.id, e); } } }) diff --git a/packages/shim/src/ui-registry.js b/packages/shim/src/ui-registry.js index 799bc61..e10ac06 100644 --- a/packages/shim/src/ui-registry.js +++ b/packages/shim/src/ui-registry.js @@ -22,5 +22,4 @@ function proxy(name) { export const showVaultManager = proxy("showVaultManager"); export const showMessageDialog = proxy("showMessageDialog"); export const showConfirmDialog = proxy("showConfirmDialog"); -export const showPluginInstallDialog = proxy("showPluginInstallDialog"); export const showPromptDialog = proxy("showPromptDialog"); diff --git a/packages/shim/src/virtual-plugin-loader.js b/packages/shim/src/virtual-plugin-loader.js index 5503e20..f04d09d 100644 --- a/packages/shim/src/virtual-plugin-loader.js +++ b/packages/shim/src/virtual-plugin-loader.js @@ -43,8 +43,8 @@ function waitForApp() { } export async function extractObsidianModule() { - if (window.__obsidian) { - return window.__obsidian; + if (window.__ignis.obsidian) { + return window.__ignis.obsidian; } await waitForApp(); @@ -97,48 +97,133 @@ export async function extractObsidianModule() { return null; } - window.__obsidian = captured; + window.__ignis.obsidian = captured; registerShim("obsidian", captured); console.log("[ignis] obsidian module captured"); return captured; } -export async function loadVirtualPlugin(entry) { - if (entry.cssUrl) { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = entry.cssUrl; - link.setAttribute("data-ignis-virtual-plugin", entry.id); - document.head.appendChild(link); - } +// Serialize per-id load/unload so rapid toggles can't race. +const inFlight = new Map(); - const res = await fetch(entry.scriptUrl); - - if (!res.ok) { - throw new Error( - `fetch ${entry.scriptUrl} -> ${res.status} ${res.statusText}`, - ); - } - - const src = - (await res.text()) + `\n//# sourceURL=ignis-virtual/${entry.id}.js`; - - const module = { exports: {} }; - const localRequire = (name) => - name === "obsidian" ? window.__obsidian : window.require(name); - - new Function("module", "exports", "require", src)( - module, - module.exports, - localRequire, - ); - - const PluginClass = module.exports.default || module.exports; - const instance = new PluginClass(window.app, entry.manifest); - - await instance.onload(); - - window.__ignis.plugins = window.__ignis.plugins || {}; - window.__ignis.plugins[entry.id] = { instance, manifest: entry.manifest }; +function serialized(id, fn) { + const prev = inFlight.get(id) || Promise.resolve(); + const next = prev.then(fn, fn); + inFlight.set(id, next); + next.finally(() => { + if (inFlight.get(id) === next) { + inFlight.delete(id); + } + }); + return next; +} + +export function loadVirtualPlugin(entry) { + return serialized(entry.id, async () => { + window.__ignis.plugins = window.__ignis.plugins || {}; + + if (window.__ignis.plugins[entry.id]) { + console.log(`[ignis] virtual plugin already loaded: ${entry.id}`); + return; + } + + if (entry.cssUrl) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = entry.cssUrl; + link.setAttribute("data-ignis-virtual-plugin", entry.id); + document.head.appendChild(link); + } + + const res = await fetch(entry.scriptUrl); + + if (!res.ok) { + throw new Error( + `fetch ${entry.scriptUrl} -> ${res.status} ${res.statusText}`, + ); + } + + const src = + (await res.text()) + `\n//# sourceURL=ignis-virtual/${entry.id}.js`; + + const module = { exports: {} }; + const localRequire = (name) => + name === "obsidian" ? window.__ignis.obsidian : window.require(name); + + new Function("module", "exports", "require", src)( + module, + module.exports, + localRequire, + ); + + const PluginClass = module.exports.default || module.exports; + const instance = new PluginClass(window.app, entry.manifest); + + // _loaded = true makes instance.unload() walk the Plugin's _register list later. + // Cleans up addCommand / addStatusBarItem / addRibbonIcon / addSettingTab / registerEvent. + instance._loaded = true; + await instance.onload(); + + window.__ignis.plugins[entry.id] = { instance, manifest: entry.manifest }; + }); +} + +export function unloadVirtualPlugin(id) { + return serialized(id, async () => { + const tracked = window.__ignis?.plugins?.[id]; + + if (!tracked) { + return; + } + + try { + await tracked.instance.unload(); + } catch (e) { + reportUnloadFailure(id, e); + } + + document + .querySelectorAll(`link[data-ignis-virtual-plugin="${id}"]`) + .forEach((el) => el.remove()); + + delete window.__ignis.plugins[id]; + }); +} + +//TODO: move to ignis API object? +function notice(text) { + try { + new window.__ignis.obsidian.Notice(text); + } catch {} +} + +export function reportLoadFailure(id, e) { + console.error(`[ignis] virtual plugin load failed: ${id}`, e); + notice(`Failed to load plugin '${id}': ${e.message}`); +} + +export function reportUnloadFailure(id, e) { + console.warn(`[ignis] virtual plugin unload failed: ${id}`, e); + notice(`Failed to unload plugin '${id}': ${e.message}`); +} + +export function watchPluginToggles(wsClient) { + wsClient.subscribe("virtual-plugin-enable", (msg) => { + if (msg.vault !== window.__currentVaultId) { + return; + } + + loadVirtualPlugin(msg.entry).catch((e) => + reportLoadFailure(msg.entry?.id, e), + ); + }); + + wsClient.subscribe("virtual-plugin-disable", (msg) => { + if (msg.vault !== window.__currentVaultId) { + return; + } + + unloadVirtualPlugin(msg.id).catch((e) => reportUnloadFailure(msg.id, e)); + }); } diff --git a/packages/shim/src/ws-client.js b/packages/shim/src/ws-client.js new file mode 100644 index 0000000..531bafa --- /dev/null +++ b/packages/shim/src/ws-client.js @@ -0,0 +1,267 @@ +// Vault-scoped WebSocket client.Single connection per shim instance. +// Multiple consumers attach via subscribe/channel. + +const RECONNECT_DELAY_MS = 2000; + +export function createWsClient() { + let ws = null; + let vaultId = null; + let reconnectTimer = null; + let manuallyClosed = false; + let state = "closed"; // "closed" | "connecting" | "open" + + const globalSubs = new Map(); // type -> Set + const channelSubs = new Map(); // channelName -> Map> + const channelSubCount = new Map(); // channelName -> integer + const stateSubs = new Set(); // handler(state) + + function setState(next) { + if (state === next) { + return; + } + + state = next; + + for (const fn of stateSubs) { + try { + fn(state); + } catch (e) { + console.error("[ws] state subscriber threw:", e); + } + } + } + + function postRaw(message) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)); + } + } + + function sendSubscribeChannel(name) { + postRaw({ type: "subscribe-channel", channel: name }); + } + + function sendUnsubscribeChannel(name) { + postRaw({ type: "unsubscribe-channel", channel: name }); + } + + function dispatch(msg) { + if (msg.channel) { + const types = channelSubs.get(msg.channel); + const handlers = types && types.get(msg.type); + + if (handlers) { + for (const fn of handlers) { + try { + fn(msg); + } catch (e) { + console.error( + `[ws] channel subscriber for ${msg.channel}:${msg.type} threw:`, + e, + ); + } + } + } + + return; + } + + const handlers = globalSubs.get(msg.type); + + if (handlers) { + for (const fn of handlers) { + try { + fn(msg); + } catch (e) { + console.error(`[ws] subscriber for ${msg.type} threw:`, e); + } + } + } + } + + function openSocket() { + if (ws) { + return; + } + + setState("connecting"); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const url = `${protocol}//${window.location.host}/ws?vault=${encodeURIComponent(vaultId)}`; + + try { + ws = new WebSocket(url); + } catch (e) { + console.error("[ws] failed to create WebSocket:", e); + ws = null; + setState("closed"); + scheduleReconnect(); + return; + } + + ws.onopen = () => { + console.log("[ws] connected"); + setState("open"); + + // Re-establish channel subscriptions on the new connection. + for (const name of channelSubCount.keys()) { + sendSubscribeChannel(name); + } + }; + + ws.onmessage = (event) => { + let msg; + + try { + msg = JSON.parse(event.data); + } catch (e) { + console.error("[ws] failed to parse message:", e); + return; + } + + dispatch(msg); + }; + + ws.onclose = () => { + ws = null; + setState("closed"); + + if (!manuallyClosed) { + scheduleReconnect(); + } + }; + + ws.onerror = (e) => { + console.error("[ws] error:", e); + }; + } + + function scheduleReconnect() { + if (reconnectTimer || manuallyClosed) { + return; + } + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + console.log("[ws] reconnecting..."); + openSocket(); + }, RECONNECT_DELAY_MS); + } + + function connect(id) { + if (!id) { + console.warn("[ws] no vault id; skipping connect"); + return; + } + + vaultId = id; + manuallyClosed = false; + openSocket(); + } + + function disconnect() { + manuallyClosed = true; + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (ws) { + ws.close(); + ws = null; + } + + setState("closed"); + } + + function subscribe(type, handler) { + if (!globalSubs.has(type)) { + globalSubs.set(type, new Set()); + } + + globalSubs.get(type).add(handler); + + return () => { + globalSubs.get(type)?.delete(handler); + }; + } + + function send(type, payload) { + postRaw({ type, ...(payload || {}) }); + } + + function channel(name) { + return { + subscribe(type, handler) { + if (!channelSubs.has(name)) { + channelSubs.set(name, new Map()); + } + + const types = channelSubs.get(name); + + if (!types.has(type)) { + types.set(type, new Set()); + } + + types.get(type).add(handler); + + // First subscriber for this channel: upgrade the server-side gate. + const prevCount = channelSubCount.get(name) || 0; + channelSubCount.set(name, prevCount + 1); + + if (prevCount === 0) { + sendSubscribeChannel(name); + } + + return () => { + const set = types.get(type); + + if (!set || !set.has(handler)) { + return; + } + + set.delete(handler); + + const newCount = (channelSubCount.get(name) || 0) - 1; + + if (newCount <= 0) { + channelSubCount.delete(name); + sendUnsubscribeChannel(name); + } else { + channelSubCount.set(name, newCount); + } + }; + }, + + send(type, payload) { + postRaw({ channel: name, type, ...(payload || {}) }); + }, + }; + } + + function isOpen() { + return state === "open"; + } + + function onStateChange(handler) { + stateSubs.add(handler); + + return () => { + stateSubs.delete(handler); + }; + } + + return { + connect, + disconnect, + subscribe, + send, + channel, + isOpen, + onStateChange, + }; +} + +// Singleton instance. The shim has one WebSocket per page; consumers all share it. +export const wsClient = createWsClient(); diff --git a/packages/ui/src/bootstrap.js b/packages/ui/src/bootstrap.js index e72c327..3ee44dd 100644 --- a/packages/ui/src/bootstrap.js +++ b/packages/ui/src/bootstrap.js @@ -47,42 +47,6 @@ function showConfirmDialog( }); } -function showPluginInstallDialog(vaultId) { - return new Promise((resolve) => { - const dialog = new window.IgnisUI.PluginInstallDialog({ - target: document.body, - }); - - dialog.$on("install", async () => { - try { - await fetch("/api/vault/install-plugin", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ vault: vaultId }), - }); - } catch (e) { - console.error("[ignis] Failed to install plugin:", e); - } - dialog.$destroy(); - resolve("install"); - }); - - dialog.$on("dismiss", async () => { - try { - await fetch("/api/vault/install-plugin", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ vault: vaultId, dismiss: true }), - }); - } catch (e) { - console.error("[ignis] Failed to dismiss plugin prompt:", e); - } - dialog.$destroy(); - resolve("dismiss"); - }); - }); -} - function showPromptDialog( title, label, @@ -113,7 +77,6 @@ if (typeof window !== "undefined" && window.__ignis_registerUI) { showVaultManager, showMessageDialog, showConfirmDialog, - showPluginInstallDialog, showPromptDialog, }); } else if (typeof window !== "undefined") { diff --git a/packages/ui/src/components/layout/PluginInstallDialog.svelte b/packages/ui/src/components/layout/PluginInstallDialog.svelte deleted file mode 100644 index 34db792..0000000 --- a/packages/ui/src/components/layout/PluginInstallDialog.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - -
-

This vault doesn't have the Ignis Bridge plugin installed.

-

- The plugin adds additional functionality such as file uploads. - Obsidian will work without it, but some features will be unavailable. -

-
- - - - -
- - diff --git a/packages/ui/src/index.js b/packages/ui/src/index.js index f262435..d830295 100644 --- a/packages/ui/src/index.js +++ b/packages/ui/src/index.js @@ -4,5 +4,4 @@ export { default as VaultManager } from "./views/VaultManager.svelte"; export { default as MessageDialog } from "./components/layout/MessageDialog.svelte"; export { default as ConfirmDialog } from "./components/layout/ConfirmDialog.svelte"; export { default as PromptDialog } from "./components/layout/PromptDialog.svelte"; -export { default as PluginInstallDialog } from "./components/layout/PluginInstallDialog.svelte"; export { default as SyncSetupModal } from "./views/SyncSetupModal.svelte";