diff --git a/.dockerignore b/.dockerignore index e288e25..6506f5d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,5 +11,4 @@ demo-vaults data tmp **/dist -packages/bridge-plugin/main.js -apps/ignis-server/server/plugins/*/plugin/main.js +apps/ignis-server/server/build-info.json diff --git a/.gitignore b/.gitignore index f1c2415..42eb83e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ dist/ investigation/ vaults/ packages/*/dist/ -packages/bridge-plugin/main.js -apps/ignis-server/server/plugins/*/plugin/main.js +apps/ignis-server/server/build-info.json demo-vaults/ data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 83285bb..66fdd61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## [0.8.3] - Karm (2026-06-01) + +### Added + +- `WS_ORIGINS` env var to restrict allowed `Origin` headers on WebSocket connections. + +### Fixed + +- Ignis version is now rendered correctly. +- Tables in editing mode now render correctly in Firefox. + ## [0.8.2] - Karm (2026-05-23) ### Fixed diff --git a/README.md b/README.md index 15e8628..a65cd0b 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi **Multi-tab and workspaces.** - Live file sync between browser tabs via WebSocket: open the same vault in two tabs and edits propagate within a second. - Saved workspaces can be opened in separate browser tabs via a `?workspace=` URL parameter, so each tab can hold a different layout of the same vault. -- The bridge plugin adds an "Open workspace in tab" command to the command palette. +- Ignis adds an "Open workspace in tab" command to the command palette. **Server-side sync.** - Obsidian Headless is implemented as a server-side plugin that performs continuous sync without needing an active browser tab. Only one of Obsidian Sync or Obsidian Headless can run per vault. diff --git a/apps/ignis-server/Dockerfile b/apps/ignis-server/Dockerfile index 95592fc..2316aaa 100644 --- a/apps/ignis-server/Dockerfile +++ b/apps/ignis-server/Dockerfile @@ -9,7 +9,7 @@ COPY package.json package-lock.json ./ COPY packages/services/package.json ./packages/services/ COPY packages/shim/package.json ./packages/shim/ COPY packages/ui/package.json ./packages/ui/ -COPY packages/bridge-plugin/package.json ./packages/bridge-plugin/ +COPY packages/bridge/package.json ./packages/bridge/ COPY packages/server-core/package.json ./packages/server-core/ COPY apps/ignis-server/package.json ./apps/ignis-server/ @@ -38,7 +38,7 @@ COPY package.json package-lock.json ./ COPY packages/services/package.json ./packages/services/ COPY packages/shim/package.json ./packages/shim/ COPY packages/ui/package.json ./packages/ui/ -COPY packages/bridge-plugin/package.json ./packages/bridge-plugin/ +COPY packages/bridge/package.json ./packages/bridge/ COPY packages/server-core/package.json ./packages/server-core/ COPY apps/ignis-server/package.json ./apps/ignis-server/ @@ -50,15 +50,11 @@ 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-plugin/manifest.json ./packages/bridge-plugin/ -COPY packages/bridge-plugin/styles.css ./packages/bridge-plugin/ - # 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-plugin/main.js ./packages/bridge-plugin/main.js -COPY --from=build /app/apps/ignis-server/server/plugins/headless-sync/plugin/main.js ./apps/ignis-server/server/plugins/headless-sync/plugin/main.js +COPY --from=build /app/apps/ignis-server/server/build-info.json ./apps/ignis-server/server/build-info.json +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/README.md b/apps/ignis-server/README.md index 5137bd4..05125be 100644 --- a/apps/ignis-server/README.md +++ b/apps/ignis-server/README.md @@ -77,6 +77,7 @@ To build from source instead of pulling the image, clone the repo and run `docke | `PUID` | User ID for file ownership | `1000` | | `PGID` | Group ID for file ownership | `1000` | | `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. Useful for slow filesystems (rclone, NFS, SMB). Set to `0` to disable. | `5000` | +| `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | unset | Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [`examples/demo/`](examples/demo/) if you want to run a public demo deployment. diff --git a/apps/ignis-server/server/assets/overrides.css b/apps/ignis-server/server/assets/overrides.css index d51613d..2a2c7ee 100644 --- a/apps/ignis-server/server/assets/overrides.css +++ b/apps/ignis-server/server/assets/overrides.css @@ -8,3 +8,9 @@ .is-hidden-frameless:not(.is-fullscreen):not(.mod-macos) .workspace-tabs.mod-top-right-space .workspace-tab-header-container:after { display: none !important; } + +/* fix table cell height in firefox in edit mode with live preview */ +.markdown-source-view.mod-cm6 .cm-table-widget th, +.markdown-source-view.mod-cm6 .cm-table-widget td { + height: auto !important; +} diff --git a/apps/ignis-server/server/bridge-plugin.js b/apps/ignis-server/server/bridge-plugin.js index a1193cf..014b1a8 100644 --- a/apps/ignis-server/server/bridge-plugin.js +++ b/apps/ignis-server/server/bridge-plugin.js @@ -1,46 +1,42 @@ const fs = require("fs"); const path = require("path"); -const { - installObsidianPlugin, - isObsidianPluginInstalled, -} = require("./plugin-system/obsidian-plugin"); const BRIDGE_PLUGIN_ID = "ignis-bridge"; -const BRIDGE_PLUGIN_DIR = path.join(__dirname, "..", "..", "..", "packages", "bridge-plugin"); -// .ignis metadata helpers +// Old vaults still have bridge in .obsidian/plugins from before it became virtual. +async function migratePluginFromVault(vaultPath, vaultName, pluginId) { + let didWork = false; -async function getIgnisMeta(vaultPath) { - const metaFile = path.join(vaultPath, ".ignis", "meta.json"); + const pluginDir = path.join(vaultPath, ".obsidian", "plugins", pluginId); + + if (await fs.promises.stat(pluginDir).catch(() => null)) { + await fs.promises.rm(pluginDir, { recursive: true, force: true }); + didWork = true; + } + + const cpFile = path.join(vaultPath, ".obsidian", "community-plugins.json"); try { - const content = await fs.promises.readFile(metaFile, "utf-8"); - return JSON.parse(content); - } catch { - return {}; + const list = JSON.parse(await fs.promises.readFile(cpFile, "utf-8")); + + if (Array.isArray(list)) { + const filtered = list.filter((id) => id !== pluginId); + + if (filtered.length !== list.length) { + await fs.promises.writeFile(cpFile, JSON.stringify(filtered)); + didWork = true; + } + } + } catch {} + + if (didWork) { + console.log(`[ignis] Migrated ${pluginId} out of vault: ${vaultName}`); } + + return didWork; } -async function setIgnisMeta(vaultPath, data) { - const ignisDir = path.join(vaultPath, ".ignis"); - const metaFile = path.join(ignisDir, "meta.json"); - - await fs.promises.mkdir(ignisDir, { recursive: true }); - await fs.promises.writeFile(metaFile, JSON.stringify(data, null, 2)); -} - -// Bridge plugin install/check - -async function isBridgePluginInstalled(vaultPath) { - return isObsidianPluginInstalled(BRIDGE_PLUGIN_ID, vaultPath); -} - -async function installBridgePlugin(vaultPath) { - const result = await installObsidianPlugin(BRIDGE_PLUGIN_DIR, vaultPath); - return result.installed; -} - -async function updateBridgePluginInAllVaults(vaultRoot) { +async function migratePluginsFromAllVaults(vaultRoot, pluginIds) { if (!(await fs.promises.stat(vaultRoot).catch(() => null))) { return; } @@ -53,18 +49,14 @@ async function updateBridgePluginInAllVaults(vaultRoot) { } const vaultPath = path.join(vaultRoot, entry.name); - const installed = await installBridgePlugin(vaultPath); - if (installed) { - console.log(`[ignis] Installed bridge plugin in vault: ${entry.name}`); + for (const pluginId of pluginIds) { + await migratePluginFromVault(vaultPath, entry.name, pluginId); } } } module.exports = { - installBridgePlugin, - updateBridgePluginInAllVaults, - isBridgePluginInstalled, - getIgnisMeta, - setIgnisMeta, + BRIDGE_PLUGIN_ID, + migratePluginsFromAllVaults, }; diff --git a/apps/ignis-server/server/config.js b/apps/ignis-server/server/config.js index 98645f4..0538dc8 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 || diff --git a/apps/ignis-server/server/demo/demo-provision.js b/apps/ignis-server/server/demo/demo-provision.js index 1d616be..a4840d8 100644 --- a/apps/ignis-server/server/demo/demo-provision.js +++ b/apps/ignis-server/server/demo/demo-provision.js @@ -1,6 +1,6 @@ // Vault provisioning for demo sessions. // -// Copies the template into a session-prefixed dir, installs the bridge plugin, and registers the vault on the session. +// Copies the template into a session-prefixed dir and registers the vault on the session. // Re-provisions if disk was wiped under an existing session. const fs = require("fs"); @@ -8,7 +8,6 @@ const fsp = fs.promises; const path = require("path"); const config = require("../config"); -const { installBridgePlugin } = require("../bridge-plugin"); const bootstrapRoutes = require("../routes/bootstrap"); const { sessions, makeStorageName } = require("./demo-sessions"); @@ -96,9 +95,6 @@ async function provisionVault(sessionId, userVaultName) { // Copy template (default: Welcome.md, Getting Started.md, .obsidian/*). await fsp.cp(config.demoTemplateDir, vaultPath, { recursive: true }); - // Install bridge plugin - await installBridgePlugin(vaultPath); - config.refreshVaults(); bootstrapRoutes.invalidateVault(storageName); diff --git a/apps/ignis-server/server/index.js b/apps/ignis-server/server/index.js index 1fa3353..bc40c64 100644 --- a/apps/ignis-server/server/index.js +++ b/apps/ignis-server/server/index.js @@ -9,8 +9,15 @@ const { watcher, writeCoalescer, } = require("@ignis/server-core"); -const { updateBridgePluginInAllVaults } = require("./bridge-plugin"); -const { initPlugins, shutdownPlugins } = require("./plugin-system/manager"); +const { + BRIDGE_PLUGIN_ID, + migratePluginsFromAllVaults, +} = require("./bridge-plugin"); +const { + initPlugins, + shutdownPlugins, + getBundledPluginDirs, +} = require("./plugin-system/manager"); const pluginRoutes = require("./routes/plugins"); writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs }); const { flushAll } = writeCoalescer; @@ -170,14 +177,28 @@ const server = app.listen(config.port, async () => { console.log(`[ignis] Vault root: ${config.vaultRoot}`); console.log(`[ignis] Vaults: ${Object.keys(config.vaults).join(", ")}`); - await updateBridgePluginInAllVaults(config.vaultRoot); await initPlugins({ app, config, wss, watcher }); + + const bundledPluginDirs = getBundledPluginDirs(); + + for (const { distDir } of bundledPluginDirs) { + app.use(express.static(distDir)); + } + + await migratePluginsFromAllVaults(config.vaultRoot, [ + BRIDGE_PLUGIN_ID, + ...bundledPluginDirs.map((d) => d.bundledPluginId), + ]); + bootstrapRoutes .warmUp() .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/discovery.js b/apps/ignis-server/server/plugin-system/discovery.js index ed710e9..bd73fdd 100644 --- a/apps/ignis-server/server/plugin-system/discovery.js +++ b/apps/ignis-server/server/plugin-system/discovery.js @@ -40,17 +40,16 @@ function discoverPlugins(pluginsDir) { continue; } - let bundledPluginId = null; + let bundledManifest = null; if (plugin.obsidianPlugin) { try { - const manifest = JSON.parse( + bundledManifest = JSON.parse( fs.readFileSync( path.join(plugin.obsidianPlugin, "manifest.json"), "utf-8", ), ); - bundledPluginId = manifest.id; } catch { // No valid bundled plugin manifest } @@ -61,7 +60,8 @@ function discoverPlugins(pluginsDir) { name: plugin.name, description: plugin.description || "", obsidianPlugin: plugin.obsidianPlugin || null, - bundledPluginId, + bundledPluginId: bundledManifest ? bundledManifest.id : null, + bundledManifest, module: plugin, }); diff --git a/apps/ignis-server/server/plugin-system/manager.js b/apps/ignis-server/server/plugin-system/manager.js index e1a84c2..3a6cd18 100644 --- a/apps/ignis-server/server/plugin-system/manager.js +++ b/apps/ignis-server/server/plugin-system/manager.js @@ -3,10 +3,7 @@ const path = require("path"); const express = require("express"); const { discoverPlugins } = require("./discovery"); const configStore = require("./config-store"); -const { - installObsidianPlugin, - removeObsidianPlugin, -} = require("./obsidian-plugin"); +const { getVersion } = require("../version"); let discoveredPlugins = new Map(); const loadedPlugins = new Map(); @@ -50,18 +47,6 @@ async function initPlugins(ctx) { continue; } - const discovered = discoveredPlugins.get(pluginId); - - if (discovered.obsidianPlugin) { - try { - await installObsidianPlugin(discovered.obsidianPlugin, vaultPath); - } catch (e) { - console.error( - `[plugins] Failed to verify bundled plugin for ${pluginId} in ${vaultId}: ${e.message}`, - ); - } - } - const loaded = loadedPlugins.get(pluginId); if (loaded?.module?.onVaultEnabled) { @@ -182,30 +167,28 @@ async function enablePluginForVault(pluginId, vaultId) { await loadPlugin(pluginId); } - if (discovered.obsidianPlugin) { - try { - const result = await installObsidianPlugin( - discovered.obsidianPlugin, - vaultPath, - ); - - if (result.installed) { - console.log( - `[plugins] Installed bundled Obsidian plugin for ${pluginId} in vault: ${vaultId}`, - ); - } - } catch (e) { - console.error( - `[plugins] Failed to install bundled plugin for ${pluginId}: ${e.message}`, - ); - } - } - const loaded = loadedPlugins.get(pluginId); 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) { @@ -227,25 +210,6 @@ async function disablePluginForVault(pluginId, vaultId) { await loaded.module.onVaultDisabled(vaultId, vaultPath); } - if (discovered.obsidianPlugin) { - try { - const result = await removeObsidianPlugin( - discovered.obsidianPlugin, - vaultPath, - ); - - if (result.removed) { - console.log( - `[plugins] Removed bundled Obsidian plugin for ${pluginId} from vault: ${vaultId}`, - ); - } - } catch (e) { - console.error( - `[plugins] Failed to remove bundled plugin for ${pluginId}: ${e.message}`, - ); - } - } - const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId); const updated = enabledVaults.filter((id) => id !== vaultId); configStore.setEnabledVaults(pluginConfig, pluginId, updated); @@ -254,6 +218,55 @@ 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() { + const dirs = []; + + for (const [, discovered] of discoveredPlugins) { + if (discovered.obsidianPlugin && discovered.bundledPluginId) { + dirs.push({ + bundledPluginId: discovered.bundledPluginId, + distDir: path.join(discovered.obsidianPlugin, "dist"), + }); + } + } + + return dirs; +} + +function getVirtualPluginsForVault(vaultId, version) { + const v = version ? `?v=${version}` : ""; + const result = []; + + for (const [pluginId, discovered] of discoveredPlugins) { + if (!discovered.obsidianPlugin || !discovered.bundledPluginId) { + continue; + } + + const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId); + + if (!enabledVaults.includes(vaultId)) { + continue; + } + + result.push({ + id: discovered.bundledPluginId, + scriptUrl: `/${discovered.bundledPluginId}.js${v}`, + cssUrl: `/${discovered.bundledPluginId}.css${v}`, + manifest: discovered.bundledManifest, + }); + } + + return result; } function getDiscoveredPlugins() { @@ -280,4 +293,6 @@ module.exports = { enablePluginForVault, disablePluginForVault, getDiscoveredPlugins, + getBundledPluginDirs, + getVirtualPluginsForVault, }; 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 4a4e679..fff96bd 100644 --- a/apps/ignis-server/server/plugins/headless-sync/index.js +++ b/apps/ignis-server/server/plugins/headless-sync/index.js @@ -11,7 +11,7 @@ module.exports = { version: "0.3.0", //TODO: add server plugin manifest - obsidianPlugin: path.join(__dirname, "plugin"), + obsidianPlugin: path.join(__dirname, "obsidian"), _ctx: null, _obStatus: null, @@ -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/plugin/manifest.json b/apps/ignis-server/server/plugins/headless-sync/obsidian/manifest.json similarity index 86% rename from apps/ignis-server/server/plugins/headless-sync/plugin/manifest.json rename to apps/ignis-server/server/plugins/headless-sync/obsidian/manifest.json index 60ab4c4..1269868 100644 --- a/apps/ignis-server/server/plugins/headless-sync/plugin/manifest.json +++ b/apps/ignis-server/server/plugins/headless-sync/obsidian/manifest.json @@ -1,6 +1,6 @@ { "id": "ignis-headless-sync", - "name": "Ignis Headless Sync", + "name": "Headless Sync", "version": "0.3.0", "minAppVersion": "1.12.4", "description": "Client-side companion for server-side Obsidian Sync", diff --git a/apps/ignis-server/server/plugins/headless-sync/plugin/src/api.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/api.js similarity index 100% rename from apps/ignis-server/server/plugins/headless-sync/plugin/src/api.js rename to apps/ignis-server/server/plugins/headless-sync/obsidian/src/api.js diff --git a/apps/ignis-server/server/plugins/headless-sync/plugin/src/auth.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/auth.js similarity index 100% rename from apps/ignis-server/server/plugins/headless-sync/plugin/src/auth.js rename to apps/ignis-server/server/plugins/headless-sync/obsidian/src/auth.js diff --git a/apps/ignis-server/server/plugins/headless-sync/plugin/src/core-sync-guard.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/core-sync-guard.js similarity index 83% rename from apps/ignis-server/server/plugins/headless-sync/plugin/src/core-sync-guard.js rename to 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/plugin/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/plugin/src/log-viewer.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/log-viewer.js similarity index 75% rename from apps/ignis-server/server/plugins/headless-sync/plugin/src/log-viewer.js rename to apps/ignis-server/server/plugins/headless-sync/obsidian/src/log-viewer.js index 45373f7..43c026d 100644 --- a/apps/ignis-server/server/plugins/headless-sync/plugin/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/plugin/src/main.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/main.js similarity index 83% rename from apps/ignis-server/server/plugins/headless-sync/plugin/src/main.js rename to apps/ignis-server/server/plugins/headless-sync/obsidian/src/main.js index 1f47399..ed9dbe9 100644 --- a/apps/ignis-server/server/plugins/headless-sync/plugin/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/plugin/src/settings-tab.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/settings-tab.js similarity index 98% rename from apps/ignis-server/server/plugins/headless-sync/plugin/src/settings-tab.js rename to apps/ignis-server/server/plugins/headless-sync/obsidian/src/settings-tab.js index c6d0f96..8c90bd2 100644 --- a/apps/ignis-server/server/plugins/headless-sync/plugin/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/plugin/src/sync-status-bar.js b/apps/ignis-server/server/plugins/headless-sync/obsidian/src/sync-status-bar.js similarity index 80% rename from apps/ignis-server/server/plugins/headless-sync/plugin/src/sync-status-bar.js rename to 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/plugin/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/plugin/styles.css b/apps/ignis-server/server/plugins/headless-sync/obsidian/styles.css similarity index 100% rename from apps/ignis-server/server/plugins/headless-sync/plugin/styles.css rename to apps/ignis-server/server/plugins/headless-sync/obsidian/styles.css diff --git a/apps/ignis-server/server/plugins/headless-sync/plugin/src/ws-listener.js b/apps/ignis-server/server/plugins/headless-sync/plugin/src/ws-listener.js deleted file mode 100644 index 2ed0264..0000000 --- a/apps/ignis-server/server/plugins/headless-sync/plugin/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/apps/ignis-server/server/routes/bootstrap.js b/apps/ignis-server/server/routes/bootstrap.js index 3a09eba..04fdc99 100644 --- a/apps/ignis-server/server/routes/bootstrap.js +++ b/apps/ignis-server/server/routes/bootstrap.js @@ -9,8 +9,11 @@ const fsp = fs.promises; const path = require("path"); const zlib = require("zlib"); const config = require("../config"); -const { isBridgePluginInstalled, getIgnisMeta } = require("../bridge-plugin"); -const { getDiscoveredPlugins } = require("../plugin-system/manager"); +const { + getDiscoveredPlugins, + getVirtualPluginsForVault, +} = require("../plugin-system/manager"); +const { getVersion } = require("../version"); const router = express.Router(); @@ -76,20 +79,13 @@ async function walkTree(rootPath) { return { tree, dirMtimes }; } -async function buildVaultInfo(vaultId, vaultPath) { - const pluginInstalled = await isBridgePluginInstalled(vaultPath); - const ignisMeta = await getIgnisMeta(vaultPath); - +function buildVaultInfo(vaultId, vaultPath) { return { id: vaultId, name: vaultId, path: vaultPath, platform: process.platform, version: config.obsidianVersion, - ignisPlugin: { - installed: pluginInstalled, - prompted: ignisMeta.pluginPrompted || false, - }, }; } @@ -134,10 +130,8 @@ async function buildEntry(vaultId) { } const t0 = Date.now(); - const [vault, { tree, dirMtimes }] = await Promise.all([ - buildVaultInfo(vaultId, vaultPath), - walkTree(vaultPath), - ]); + const vault = buildVaultInfo(vaultId, vaultPath); + const { tree, dirMtimes } = await walkTree(vaultPath); const response = { vault, @@ -145,6 +139,7 @@ async function buildEntry(vaultId) { tree, // In demo mode, hide server-side plugins from the client. plugins: config.demoMode ? [] : getDiscoveredPlugins(), + virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()), }; const jsonBuf = Buffer.from(JSON.stringify(response)); diff --git a/apps/ignis-server/server/routes/vault.js b/apps/ignis-server/server/routes/vault.js index befccb1..438298b 100644 --- a/apps/ignis-server/server/routes/vault.js +++ b/apps/ignis-server/server/routes/vault.js @@ -2,12 +2,6 @@ const express = require("express"); const fs = require("fs"); const config = require("../config"); const path = require("path"); -const { - isBridgePluginInstalled, - getIgnisMeta, - setIgnisMeta, - installBridgePlugin, -} = require("../bridge-plugin"); const bootstrapRoutes = require("./bootstrap"); const router = express.Router(); @@ -34,19 +28,12 @@ router.get("/info", async (req, res) => { return res.status(404).json({ error: "Vault not found", id: vaultId }); } - const pluginInstalled = await isBridgePluginInstalled(vaultPath); - const ignisMeta = await getIgnisMeta(vaultPath); - res.json({ id: vaultId, name: vaultId, path: vaultPath, platform: process.platform, version: config.obsidianVersion, - ignisPlugin: { - installed: pluginInstalled, - prompted: ignisMeta.pluginPrompted || false, - }, }); }); @@ -66,8 +53,6 @@ router.post("/create", async (req, res) => { recursive: false, }); - await installBridgePlugin(vaultPath); - config.refreshVaults(); bootstrapRoutes.invalidateVault(name); @@ -138,42 +123,4 @@ router.delete("/remove", async (req, res) => { } }); -// POST /api/vault/install-plugin { vault, dismiss } - install plugin or mark as prompted -router.post("/install-plugin", async (req, res) => { - const vaultId = req.body?.vault; - const dismiss = req.body?.dismiss || false; - - if (!vaultId) { - return res.status(400).json({ error: "Missing vault ID" }); - } - - const vaultPath = config.getVaultPath(vaultId); - - if (!vaultPath) { - return res.status(404).json({ error: "Vault not found" }); - } - - try { - const meta = await getIgnisMeta(vaultPath); - - if (dismiss) { - // User clicked "Don't Ask Again" or "Not Now" - meta.pluginPrompted = true; - await setIgnisMeta(vaultPath, meta); - - return res.json({ ok: true, prompted: true }); - } else { - // User wants to install the plugin - const installed = await installBridgePlugin(vaultPath); - - meta.pluginPrompted = true; - await setIgnisMeta(vaultPath, meta); - - return res.json({ ok: true, installed, prompted: true }); - } - } catch (e) { - res.status(500).json({ error: e.message, code: e.code }); - } -}); - module.exports = router; diff --git a/apps/ignis-server/server/routes/version.js b/apps/ignis-server/server/routes/version.js index 9e2c9b3..8421c1c 100644 --- a/apps/ignis-server/server/routes/version.js +++ b/apps/ignis-server/server/routes/version.js @@ -1,15 +1,14 @@ const express = require("express"); -const { getVersion } = require("../version"); +const { getSemver, getBuild } = require("../version"); const config = require("../config"); const router = express.Router(); +// `version` is the display-friendly SemVer. `build` is the per-build stamp for cache-bust. router.get("/", (req, res) => { - const pkg = require("../../package.json"); - res.json({ - version: getVersion(), - semver: pkg.version, + version: getSemver(), + build: getBuild(), obsidianVersion: config.obsidianVersion, }); }); diff --git a/apps/ignis-server/server/version.js b/apps/ignis-server/server/version.js index ed8788d..acb6ec3 100644 --- a/apps/ignis-server/server/version.js +++ b/apps/ignis-server/server/version.js @@ -1,23 +1,51 @@ const fs = require("fs"); const path = require("path"); -const { execSync } = require("child_process"); -function getVersion() { - const pkg = JSON.parse( - fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"), - ); - const semver = pkg.version; +let cached = null; - let hash; - try { - hash = execSync("git rev-parse --short=7 HEAD", { - encoding: "utf-8", - }).trim(); - } catch (e) { - hash = Date.now().toString(36).slice(-7); +function load() { + if (cached) { + return cached; } - return `${semver}-${hash}`; + // Production: root build.js writes this next to us. + try { + cached = JSON.parse( + fs.readFileSync(path.join(__dirname, "build-info.json"), "utf-8"), + ); + return cached; + } catch {} + + // Local dev fallback. Read root package.json. + try { + const pkg = JSON.parse( + fs.readFileSync( + path.join(__dirname, "..", "..", "..", "package.json"), + "utf-8", + ), + ); + cached = { + semver: pkg.version, + build: "dev", + version: `${pkg.version}-dev`, + }; + return cached; + } catch {} + + cached = { semver: "0.0.0", build: "unknown", version: "0.0.0-unknown" }; + return cached; } -module.exports = { getVersion }; +function getVersion() { + return load().version; +} + +function getSemver() { + return load().semver; +} + +function getBuild() { + return load().build; +} + +module.exports = { getVersion, getSemver, getBuild }; diff --git a/build.js b/build.js index ee6cc29..69b93a8 100644 --- a/build.js +++ b/build.js @@ -1,6 +1,38 @@ const esbuild = require("esbuild"); +const fs = require("fs"); const path = require("path"); +const headlessSyncDir = path.join( + __dirname, + "apps", + "ignis-server", + "server", + "plugins", + "headless-sync", + "obsidian", +); + +// Compute version info once and share across per-package builds. +const { version: semver } = require("./package.json"); +const build = process.env.IGNIS_BUILD || Date.now().toString(36).slice(-7); +const version = `${semver}+${build}`; + +const buildInfoPath = path.join( + __dirname, + "apps", + "ignis-server", + "server", + "build-info.json", +); + +fs.writeFileSync( + buildInfoPath, + JSON.stringify({ semver, build, version }, null, 2), +); + +// Used by packages. +process.env.IGNIS_BUILD_RESOLVED = build; + Promise.all([ // Build shim-loader.js (delegated to packages/shim) require("./packages/shim/build.js"), @@ -8,39 +40,22 @@ Promise.all([ // Build ignis-ui.js (delegated to packages/ui) require("./packages/ui/build.js"), - // Build ignis-bridge plugin (delegated to packages/bridge-plugin) - require("./packages/bridge-plugin/build.js"), - // Build headless-sync bundled plugin - esbuild.build({ - entryPoints: [ - path.join( - __dirname, - "apps", - "ignis-server", - "server", - "plugins", - "headless-sync", - "plugin", - "src", - "main.js", - ), - ], - bundle: true, - outfile: path.join( - __dirname, - "apps", - "ignis-server", - "server", - "plugins", - "headless-sync", - "plugin", - "main.js", - ), - format: "cjs", - platform: "browser", - target: ["chrome90"], - external: ["obsidian", "fs"], //using fs shim - logLevel: "info", - }), + esbuild + .build({ + entryPoints: [path.join(headlessSyncDir, "src", "main.js")], + bundle: true, + outfile: path.join(headlessSyncDir, "dist", "ignis-headless-sync.js"), + format: "cjs", + platform: "browser", + target: ["chrome90"], + external: ["obsidian", "fs"], + logLevel: "info", + }) + .then(() => { + fs.copyFileSync( + path.join(headlessSyncDir, "styles.css"), + path.join(headlessSyncDir, "dist", "ignis-headless-sync.css"), + ); + }), ]).catch(() => process.exit(1)); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 86d346b..fe7c687 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,12 +13,13 @@ Ignis runs Obsidian in a browser by replacing its Electron backend with a shim l - [IPC](#ipc) - [Cross-origin requests](#cross-origin-requests) - [Workspaces in browser tabs](#workspaces-in-browser-tabs) +- [Bridge](#bridge) - [Vaults](#vaults) - [Server](#server) - [Plugins](#plugins) - [Obsidian Plugins](#obsidian-plugins) - - [Bridge Plugin (ignis-bridge)](#bridge-plugin-ignis-bridge) - [Ignis Plugins](#ignis-plugins) + - [Virtual Plugins](#virtual-plugins) - [Demo mode](#demo-mode) ## Overview @@ -31,13 +32,13 @@ Browser Server │ Shim layer │ <────> │ /api/vault/* │ │ fs, electron, etc. │ WS │ /api/plugins/* │ │ ↕ │ <────> │ /api/ext/:plugin/* │ -│ Bridge plugin │ │ Ignis plugins │ +│ Bridge │ │ Ignis plugins │ └──────────────────────┘ └──────────────────────┘ ↕ Filesystem (vaults/) ``` -The shim layer makes Obsidian think it's running in Electron. The bridge plugin adds Ignis-specific features inside Obsidian. +The shim layer makes Obsidian think it's running in Electron. The bridge adds Ignis-specific features inside Obsidian. ## Shim Layer @@ -111,6 +112,18 @@ The implementation uses all three transforms (above): a path resolver redirects Two tabs in the same workspace share the same state file and stay in sync through the file watcher. Two tabs in different workspaces hold independent layout state. +## Bridge + +Ignis's built-in integration with the Obsidian UI. It subclasses Obsidian's `Plugin` to get convenient hooks (commands, ribbon icons, status bar items, settings tabs, workspace events), but it is not a plugin in the managed sense: it isn't discovered, toggled, enabled per vault, or installed into `.obsidian/plugins/`. It's bundled into `shim-loader.js` (source in `packages/bridge/`), instantiated directly by the shim loader after Obsidian boots, and always on. + +The bridge contributes: + +- **File actions**: a ribbon icon for uploading files into the current folder, and right-click menu items: Download (single file), Download as ZIP (folder), and Upload file (folder). +- **Commands**: `Open workspace in new tab`. +- **Status bar item**: a dot showing the WebSocket connection state to the Ignis server. +- **Settings injection**: monkey-patches `app.setting.onOpen` to add two tabs in their own "Ignis" sidebar group. Each enabled Ignis plugin's companion is pulled into a separate "Ignis Core Plugins" sidebar group. +- **Demo guards**: in demo mode, a MutationObserver disables every email/password input that appears anywhere in the document. + ## Vaults Any subdirectory under the vault root is treated as a vault. The active vault is selected via a `?vault=` URL parameter. Without the queryparam, the last active vault is loaded (from `localStorage.last-vault`), or the first discovered. @@ -124,45 +137,38 @@ An Express server that handles filesystem operations, vault management, static f - `/api/vault/*` - vault CRUD and config. - `/api/bootstrap` - one-shot cold-start endpoint; returns vault info + list + metadata tree + plugin list as a single pre-compressed response, cached per vault with mtime-based invalidation. - `/api/proxy` - cross-origin HTTP proxy used by the fetch and requestUrl shims. -- `/api/version` - server version and git hash. +- `/api/version` - Ignis version (SemVer), per-build identifier, and pinned Obsidian version. - `/api/plugins/*` - Ignis plugin management (list, enable, disable). __WIP__ - `/api/ext/:pluginId/*` - routes registered by individual Ignis plugins. - `/vault-files//` - static file serving rooted at a vault, used by Obsidian for image/attachment resource URLs. **WebSocket:** A file watcher monitors vault directories and pushes change events to connected clients, keeping the client-side metadata and content caches in sync. An echo guard suppresses events caused by the same client's recent writes so they don't bounce back. The watcher also carries plugin-defined message types (e.g. headless-sync status broadcasts). -**Bridge plugin auto-install:** On server startup and on vault creation, the server copies the ignis-bridge plugin into each vault's `.obsidian/plugins/` directory. +**Legacy bridge cleanup:** Earlier versions installed the bridge into each vault's `.obsidian/plugins/`. The bridge is now bundled into the shim and loaded client-side, so on startup the server removes any leftover on-disk `ignis-bridge` install from each vault (and strips it from `community-plugins.json`). ## Plugins -Three things are called "plugin" in this project. +Aside from the built-in [Bridge](#bridge), three kinds of plugin exist in Ignis, distinguished by who loads them and where they run. ### Obsidian Plugins Standard community and core Obsidian plugins. Obsidian evals plugin code with its own require that checks its internal module map first, then falls back to the window-level require, which Ignis replaces with the shim. Plugins that use the filesystem, path utilities, or crypto get shim implementations transparently. Plugins that need child processes, raw sockets, or native addons load but throw on first use; the error message names the missing API. -### Bridge Plugin (ignis-bridge) - -An Obsidian plugin auto-installed into every vault by the server. Source lives in `packages/bridge-plugin/`, built to `packages/bridge-plugin/main.js`. - -It contributes: -- **File actions**: a ribbon icon for uploading files into the current folder, and right-click menu items: Download (single file), Download as ZIP (folder), and Upload file (folder). -- **Commands**: `Open workspace in new tab` (with a FuzzySuggestModal listing saved workspaces). -- **Status bar item**: a dot showing the WebSocket connection state to the Ignis server. -- **Settings injection**: monkey-patches `app.setting.onOpen` to add two tabs in their own "Ignis" sidebar group. General (server status, version, GitHub link, update check against the GitHub releases API) and Core plugins (toggle the bundled Obsidian plugins of enabled Ignis plugins on/off per vault). Each enabled Ignis plugin's bundled Obsidian plugin also gets pulled into a separate "Ignis Core Plugins" sidebar group. -- **Demo guards**: in demo mode, a MutationObserver disables every email/password input that appears anywhere in the document and rewrites its placeholder. - -Not user-installable through Obsidian's plugin browser. Managed entirely by the server. - ### Ignis Plugins -A basic plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change. +A plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change. -An Ignis plugin is a Node.js package under `apps/ignis-server/server/plugins//` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`. +An Ignis plugin is a Node.js package under `apps/ignis-server/server/plugins//` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`. When enabled, a plugin's Express router is mounted at `/api/ext//`. -When enabled, a plugin's Express router is mounted at `/api/ext//`. A plugin can also optionally bundle an Obsidian plugin, a directory containing a standard Obsidian plugin (manifest.json, main.js) that gets auto-installed into the vault on enable and removed on disable. This bridges the server and client sides: the Ignis plugin handles server logic and routes, while the bundled Obsidian plugin provides the in-app UI or behavior. +An Ignis plugin can optionally ship a **virtual plugin** (see below): an Obsidian-side companion that provides the in-app UI. The Ignis plugin handles server logic and routes; the virtual plugin runs in the browser. -The one Ignis plugin currently in the repo is **headless-sync** (`apps/ignis-server/server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/Yuri-Khomyakov/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast over the WebSocket via a small per-vault subscription protocol. The bundled Obsidian plugin (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it. +The one Ignis plugin currently in the repo is **headless-sync** (`apps/ignis-server/server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/obsidianmd/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast to subscribed clients over a WebSocket channel. + +### Virtual Plugins + +The client-side companion of an Ignis plugin: a standard Obsidian plugin (a `manifest.json` plus a bundled script) that Ignis loads in the browser rather than installing to disk. The virtual-plugin-loader (`packages/shim/src/virtual-plugin-loader.js`) fetches the bundle from the server, evals it, instantiates the plugin class against the live `app`. Loaded instances are tracked in `window.__ignis.plugins` and can be toggled per vault. Nothing is ever written to `.obsidian/plugins/`. + +headless-sync's companion (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it. ## Demo mode diff --git a/package-lock.json b/package-lock.json index 3da0f56..f0d4a48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ignis-monorepo", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ignis-monorepo", - "version": "0.8.1", + "version": "0.8.2", "workspaces": [ "packages/*", "apps/*" @@ -498,8 +498,8 @@ "resolved": "apps/ignis-server", "link": true }, - "node_modules/@ignis/bridge-plugin": { - "resolved": "packages/bridge-plugin", + "node_modules/@ignis/bridge": { + "resolved": "packages/bridge", "link": true }, "node_modules/@ignis/server-core": { @@ -4428,9 +4428,17 @@ "node": ">= 14" } }, + "packages/bridge": { + "name": "@ignis/bridge", + "version": "0.0.0-internal", + "devDependencies": { + "esbuild": "^0.20.0" + } + }, "packages/bridge-plugin": { "name": "@ignis/bridge-plugin", "version": "0.0.0-internal", + "extraneous": true, "devDependencies": { "esbuild": "^0.20.0" } diff --git a/package.json b/package.json index 5b45b44..ebad16b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ignis-monorepo", - "version": "0.8.2", + "version": "0.8.3", "private": true, "description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.", "workspaces": [ diff --git a/packages/bridge-plugin/build.js b/packages/bridge-plugin/build.js deleted file mode 100644 index 37f6a9d..0000000 --- a/packages/bridge-plugin/build.js +++ /dev/null @@ -1,13 +0,0 @@ -const esbuild = require("esbuild"); -const path = require("path"); - -module.exports = esbuild.build({ - entryPoints: [path.join(__dirname, "src", "main.js")], - bundle: true, - outfile: path.join(__dirname, "main.js"), - format: "cjs", - platform: "browser", - target: ["chrome90"], - external: ["obsidian", "fs"], - logLevel: "info", -}); diff --git a/packages/bridge-plugin/manifest.json b/packages/bridge-plugin/manifest.json deleted file mode 100644 index 4ef8f86..0000000 --- a/packages/bridge-plugin/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "ignis-bridge", - "name": "Ignis Bridge", - "version": "0.8.1", - "minAppVersion": "1.12.4", - "description": "Additional Ignis specific functionality and ignis plugin management.", - "author": "Nystik", - "authorUrl": "https://github.com/Nystik-gh/ignis", - "isDesktopOnly": false -} diff --git a/packages/bridge-plugin/package.json b/packages/bridge-plugin/package.json deleted file mode 100644 index 2ff2478..0000000 --- a/packages/bridge-plugin/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@ignis/bridge-plugin", - "version": "0.0.0-internal", - "private": true, - "scripts": { - "build": "node build.js" - }, - "devDependencies": { - "esbuild": "^0.20.0" - } -} diff --git a/packages/bridge-plugin/src/status-bar.js b/packages/bridge-plugin/src/status-bar.js deleted file mode 100644 index f93b387..0000000 --- a/packages/bridge-plugin/src/status-bar.js +++ /dev/null @@ -1,48 +0,0 @@ -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", - connecting: "Ignis server: Connecting...", - disconnected: "Ignis server: Disconnected", -}; - -function initStatusBar(plugin) { - const item = plugin.addStatusBarItem(); - item.addClass("ignis-statusbar-item"); - - const dot = item.createEl("span", { - 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"); - }; - - update(); - - const interval = setInterval(update, 3000); - - return interval; -} - -module.exports = { initStatusBar }; diff --git a/packages/bridge/package.json b/packages/bridge/package.json new file mode 100644 index 0000000..7dadf12 --- /dev/null +++ b/packages/bridge/package.json @@ -0,0 +1,6 @@ +{ + "name": "@ignis/bridge", + "version": "0.0.0-internal", + "private": true, + "main": "src/main.js" +} diff --git a/packages/bridge-plugin/src/demo-guards.js b/packages/bridge/src/demo-guards.js similarity index 100% rename from packages/bridge-plugin/src/demo-guards.js rename to packages/bridge/src/demo-guards.js diff --git a/packages/bridge-plugin/src/file-actions.js b/packages/bridge/src/file-actions.js similarity index 100% rename from packages/bridge-plugin/src/file-actions.js rename to packages/bridge/src/file-actions.js diff --git a/packages/bridge-plugin/src/main.js b/packages/bridge/src/main.js similarity index 97% rename from packages/bridge-plugin/src/main.js rename to packages/bridge/src/main.js index 8d46554..3dae017 100644 --- a/packages/bridge-plugin/src/main.js +++ b/packages/bridge/src/main.js @@ -13,8 +13,6 @@ const { initStatusBar } = require("./status-bar"); const { WorkspacePickerModal } = require("./workspace-picker"); const { startDemoGuards, stopDemoGuards } = require("./demo-guards"); -window.__obsidianAPI = require("obsidian"); - class IgnisBridgePlugin extends Plugin { async onload() { if (!window.__ignis) { diff --git a/packages/bridge-plugin/src/plugin-registry.js b/packages/bridge/src/plugin-registry.js similarity index 100% rename from packages/bridge-plugin/src/plugin-registry.js rename to packages/bridge/src/plugin-registry.js diff --git a/packages/bridge-plugin/src/settings/general-tab.js b/packages/bridge/src/settings/general-tab.js similarity index 70% rename from packages/bridge-plugin/src/settings/general-tab.js rename to packages/bridge/src/settings/general-tab.js index b18f1a6..bfcff57 100644 --- a/packages/bridge-plugin/src/settings/general-tab.js +++ b/packages/bridge/src/settings/general-tab.js @@ -4,13 +4,8 @@ const GITHUB_URL = "https://github.com/Nystik-gh/ignis"; const GITHUB_API_LATEST = "https://api.github.com/repos/Nystik-gh/ignis/releases/latest"; -function getVersion(app) { - try { - const manifest = app.plugins.getPlugin("ignis-bridge")?.manifest; - return manifest?.version || "unknown"; - } catch { - return "unknown"; - } +function getVersion() { + return window.__ignis?.version || "unknown"; } // SemVer build metadata (`+xyz`) is informational and ignored for precedence. @@ -41,7 +36,7 @@ async function checkForUpdate(currentVersion) { } function display(containerEl, app) { - const version = getVersion(app); + const version = getVersion(); const header = containerEl.createDiv("ignis-header"); @@ -95,64 +90,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-plugin/src/settings/inject.js b/packages/bridge/src/settings/inject.js similarity index 90% rename from packages/bridge-plugin/src/settings/inject.js rename to packages/bridge/src/settings/inject.js index 9e0cb2d..427c0e9 100644 --- a/packages/bridge-plugin/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-plugin/src/settings/plugin-tabs.js b/packages/bridge/src/settings/plugin-tabs.js similarity index 83% rename from packages/bridge-plugin/src/settings/plugin-tabs.js rename to packages/bridge/src/settings/plugin-tabs.js index 1d7bea7..a86da07 100644 --- a/packages/bridge-plugin/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-plugin/src/settings/server-plugins-tab.js b/packages/bridge/src/settings/server-plugins-tab.js similarity index 64% rename from packages/bridge-plugin/src/settings/server-plugins-tab.js rename to packages/bridge/src/settings/server-plugins-tab.js index dbb940e..bff53e4 100644 --- a/packages/bridge-plugin/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-plugin/src/settings/settings-ui.js b/packages/bridge/src/settings/settings-ui.js similarity index 100% rename from packages/bridge-plugin/src/settings/settings-ui.js rename to packages/bridge/src/settings/settings-ui.js diff --git a/packages/bridge/src/status-bar.js b/packages/bridge/src/status-bar.js new file mode 100644 index 0000000..e3f882d --- /dev/null +++ b/packages/bridge/src/status-bar.js @@ -0,0 +1,35 @@ +const STATUS_LABELS = { + open: "Ignis server: Connected", + connecting: "Ignis server: Connecting...", + 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"); + + const dot = item.createEl("span", { + cls: "ignis-statusbar-dot", + }); + + item.setAttribute("data-tooltip-position", "top"); + + 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); + } + + render(ws.isOpen() ? "open" : "closed"); + + return ws.onStateChange(render); +} + +module.exports = { initStatusBar }; diff --git a/packages/bridge-plugin/src/workspace-picker.js b/packages/bridge/src/workspace-picker.js similarity index 100% rename from packages/bridge-plugin/src/workspace-picker.js rename to packages/bridge/src/workspace-picker.js diff --git a/packages/bridge-plugin/styles.css b/packages/bridge/styles.css similarity index 100% rename from packages/bridge-plugin/styles.css rename to packages/bridge/styles.css 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/build.js b/packages/shim/build.js index 265023c..d92043a 100644 --- a/packages/shim/build.js +++ b/packages/shim/build.js @@ -1,7 +1,10 @@ const esbuild = require("esbuild"); const path = require("path"); -const { version: ignisVersion } = require("../../package.json"); +const { version: semver } = require("../../package.json"); + +// Root build.js sets IGNIS_BUILD_RESOLVED when it runs first; standalone invocation falls back to a dev stamp. +const build = process.env.IGNIS_BUILD_RESOLVED || "dev"; module.exports = esbuild.build({ entryPoints: [path.join(__dirname, "src", "loader.js")], @@ -13,8 +16,13 @@ module.exports = esbuild.build({ alias: { path: "path-browserify", }, + loader: { + ".css": "text", + }, + external: ["obsidian", "fs"], define: { - __IGNIS_VERSION__: JSON.stringify(ignisVersion), + __IGNIS_VERSION__: JSON.stringify(semver), + __IGNIS_BUILD__: JSON.stringify(build), }, logLevel: "info", }); diff --git a/packages/shim/src/css-overrides.js b/packages/shim/src/css-overrides.js index c87ada4..2fc71b3 100644 --- a/packages/shim/src/css-overrides.js +++ b/packages/shim/src/css-overrides.js @@ -1,4 +1,4 @@ -// Injects a link to the CSS overrides stylesheet served from /assets/overrides.css. +import bridgeCss from "@ignis/bridge/styles.css"; export function installCssOverrides() { const link = document.createElement("link"); @@ -6,4 +6,9 @@ export function installCssOverrides() { link.href = "/assets/overrides.css"; link.setAttribute("data-ignis", "css-overrides"); document.head.appendChild(link); + + const bridgeStyle = document.createElement("style"); + bridgeStyle.textContent = bridgeCss; + bridgeStyle.setAttribute("data-ignis", "bridge-css"); + document.head.appendChild(bridgeStyle); } 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/promises.js b/packages/shim/src/fs/promises.js index 3b4d908..565ecb9 100644 --- a/packages/shim/src/fs/promises.js +++ b/packages/shim/src/fs/promises.js @@ -1,6 +1,7 @@ import { markLocalOp } from "./echo-guard.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js"; import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js"; +import { hasVirtualFile, getVirtualFile } from "./virtual-files.js"; export function createFsPromises(metadataCache, contentCache, transport) { return { @@ -49,6 +50,21 @@ export function createFsPromises(metadataCache, contentCache, transport) { const wantText = encoding === "utf8" || encoding === "utf-8"; const resolved = resolvePath(path); + // Virtual plugin source overrides any cache/transport version. + if (hasVirtualFile(resolved)) { + const content = getVirtualFile(resolved); + + if (wantText) { + return typeof content === "string" + ? content + : new TextDecoder().decode(content); + } + + return typeof content === "string" + ? new TextEncoder().encode(content) + : content; + } + let result = null; // Check input cache for files picked via browser file dialogs. diff --git a/packages/shim/src/fs/virtual-files.js b/packages/shim/src/fs/virtual-files.js new file mode 100644 index 0000000..65078f0 --- /dev/null +++ b/packages/shim/src/fs/virtual-files.js @@ -0,0 +1,23 @@ +// Virtual plugin source served from memory; the fs shim's read path checks here before disk. + +function normalize(p) { + return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); +} + +const virtualFiles = new Map(); + +export function setVirtualFile(path, content) { + virtualFiles.set(normalize(path), content); +} + +export function removeVirtualFile(path) { + virtualFiles.delete(normalize(path)); +} + +export function getVirtualFile(path) { + return virtualFiles.get(normalize(path)); +} + +export function hasVirtualFile(path) { + return virtualFiles.has(normalize(path)); +} 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 0d25dda..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,6 +211,7 @@ export function initialize() { autoTrustDemoVaults(bootstrap.vaultList); applyTree(bootstrap.tree); applyCoreSyncGuard(bootstrap.plugins); + 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. @@ -249,5 +229,4 @@ export function initialize() { installRequestUrlShim(); initWorkspacePatch(); - initPluginPrompt(); } diff --git a/packages/shim/src/loader.js b/packages/shim/src/loader.js index 0319de4..19a1b07 100644 --- a/packages/shim/src/loader.js +++ b/packages/shim/src/loader.js @@ -1,14 +1,36 @@ 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__ }; +// __IGNIS_VERSION__ (semver) and __IGNIS_BUILD__ are replaced at build time. +window.__ignis = { version: __IGNIS_VERSION__, build: __IGNIS_BUILD__ }; window.__ignis_registerUI = registerUI; +installIgnisApi(wsClient); + +const BRIDGE_MANIFEST = { + id: "ignis-bridge", + name: "Ignis Bridge", + version: __IGNIS_VERSION__, + minAppVersion: "1.12.4", + description: + "Additional Ignis specific functionality and ignis plugin management.", + author: "Nystik", + authorUrl: "https://github.com/Nystik-gh/ignis", + isDesktopOnly: false, +}; + installGlobals(); // process, Buffer, window overrides (before require so Buffer is available) installRequire(); // shim registry, window.require installCssOverrides(); // browser-specific CSS fixes @@ -22,9 +44,30 @@ 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() + .then(async () => { + // Dynamic import so bridge's top-level require("obsidian") fires after installRequire + extractObsidianModule. + const mod = await import("@ignis/bridge"); + const IgnisBridgePlugin = mod.default || mod; + const bridge = new IgnisBridgePlugin(window.app, BRIDGE_MANIFEST); + await bridge.onload(); + console.log("[ignis] bridge loaded"); + + for (const vp of getBootstrapVirtualPlugins()) { + try { + await loadVirtualPlugin(vp); + console.log(`[ignis] virtual plugin loaded: ${vp.id}`); + } catch (e) { + reportLoadFailure(vp.id, e); + } + } + }) + .catch((e) => console.error("[ignis] bridge load failed:", e)); + console.log("[ignis] Shim loader initialized"); diff --git a/packages/shim/src/require.js b/packages/shim/src/require.js index d2a9a55..1444460 100644 --- a/packages/shim/src/require.js +++ b/packages/shim/src/require.js @@ -67,3 +67,8 @@ export function installRequire() { installDebugHelpers(rawRegistry); } + +// For modules captured at runtime, e.g. the obsidian module via the virtual-plugin loader. +export function registerShim(name, mod) { + shimRegistry[name] = mod; +} 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 new file mode 100644 index 0000000..f04d09d --- /dev/null +++ b/packages/shim/src/virtual-plugin-loader.js @@ -0,0 +1,229 @@ +// Capture the obsidian module via a one-shot synthetic plugin so virtual plugins (bridge, future bundled) can require("obsidian"). + +import { setVirtualFile, removeVirtualFile } from "./fs/virtual-files.js"; +import { registerShim } from "./require.js"; + +const EXTRACTOR_ID = "ignis-obsidian-extractor"; +const EXTRACTOR_DIR = ".ignis/virtual/" + EXTRACTOR_ID; +const EXTRACTOR_PATH = EXTRACTOR_DIR + "/main.js"; + +const EXTRACTOR_SRC = ` +const obsidian = require("obsidian"); +window.__ignisCapturedObsidian = obsidian; +module.exports = class extends obsidian.Plugin { + onload() {} +}; +`; + +const EXTRACTOR_MANIFEST = { + id: EXTRACTOR_ID, + name: "Ignis Obsidian Module Extractor", + version: "0.0.0", + minAppVersion: "1.0.0", + description: "Internal: captures the obsidian module for virtual plugins.", + author: "ignis", + authorUrl: "", + isDesktopOnly: false, + dir: EXTRACTOR_DIR, +}; + +function waitForApp() { + return new Promise((resolve) => { + if (window.app && window.app.plugins && window.app.workspace) { + return resolve(); + } + + const interval = setInterval(() => { + if (window.app && window.app.plugins && window.app.workspace) { + clearInterval(interval); + resolve(); + } + }, 20); + }); +} + +export async function extractObsidianModule() { + if (window.__ignis.obsidian) { + return window.__ignis.obsidian; + } + + await waitForApp(); + + const plugins = window.app.plugins; + + // loadPlugin gates on isEnabled(). Force-enable, restore on cleanup. + const wasEnabled = plugins.isEnabled(); + let toggledOn = false; + + if (!wasEnabled) { + try { + await plugins.setEnable(true); + toggledOn = true; + } catch (e) { + console.warn( + "[ignis] could not enable community plugins for extractor:", + e, + ); + } + } + + setVirtualFile(EXTRACTOR_PATH, EXTRACTOR_SRC); + plugins.manifests[EXTRACTOR_ID] = EXTRACTOR_MANIFEST; + + try { + await plugins.loadPlugin(EXTRACTOR_ID); + } catch (e) { + console.error("[ignis] extractor load failed:", e); + } + + const captured = window.__ignisCapturedObsidian; + + try { + await plugins.unloadPlugin(EXTRACTOR_ID); + } catch {} + + delete plugins.manifests[EXTRACTOR_ID]; + removeVirtualFile(EXTRACTOR_PATH); + delete window.__ignisCapturedObsidian; + + if (toggledOn) { + try { + await plugins.setEnable(false); + } catch {} + } + + if (!captured) { + console.error("[ignis] obsidian module extraction failed"); + return null; + } + + window.__ignis.obsidian = captured; + registerShim("obsidian", captured); + + console.log("[ignis] obsidian module captured"); + return captured; +} + +// Serialize per-id load/unload so rapid toggles can't race. +const inFlight = new Map(); + +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";