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

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

-

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

-
- - - - -
- - diff --git a/packages/ui/src/index.js b/packages/ui/src/index.js index f262435..d830295 100644 --- a/packages/ui/src/index.js +++ b/packages/ui/src/index.js @@ -4,5 +4,4 @@ export { default as VaultManager } from "./views/VaultManager.svelte"; export { default as MessageDialog } from "./components/layout/MessageDialog.svelte"; export { default as ConfirmDialog } from "./components/layout/ConfirmDialog.svelte"; export { default as PromptDialog } from "./components/layout/PromptDialog.svelte"; -export { default as PluginInstallDialog } from "./components/layout/PluginInstallDialog.svelte"; export { default as SyncSetupModal } from "./views/SyncSetupModal.svelte"; From d5fb9e1e1db4f0ec0df6a0bf81759f933d8cf7db Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Tue, 26 May 2026 02:55:24 +0200 Subject: [PATCH 6/9] update build process and versioning --- .dockerignore | 2 +- .gitignore | 2 +- apps/ignis-server/Dockerfile | 1 + apps/ignis-server/server/config.js | 1 - .../headless-sync/obsidian/manifest.json | 2 +- apps/ignis-server/server/routes/version.js | 9 ++- apps/ignis-server/server/version.js | 58 ++++++++++++++----- build.js | 21 +++++++ packages/bridge/src/settings/general-tab.js | 11 +--- packages/shim/build.js | 8 ++- packages/shim/src/loader.js | 4 +- 11 files changed, 83 insertions(+), 36 deletions(-) diff --git a/.dockerignore b/.dockerignore index 02c0045..6506f5d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,4 +11,4 @@ demo-vaults data tmp **/dist -packages/bridge/main.js +apps/ignis-server/server/build-info.json diff --git a/.gitignore b/.gitignore index 0bf12f3..42eb83e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,6 @@ dist/ investigation/ vaults/ packages/*/dist/ -packages/bridge/main.js +apps/ignis-server/server/build-info.json demo-vaults/ data/ diff --git a/apps/ignis-server/Dockerfile b/apps/ignis-server/Dockerfile index 0f516ba..2316aaa 100644 --- a/apps/ignis-server/Dockerfile +++ b/apps/ignis-server/Dockerfile @@ -53,6 +53,7 @@ COPY packages/server-core/src/ ./packages/server-core/src/ # 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/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/server/config.js b/apps/ignis-server/server/config.js index b04903b..0538dc8 100644 --- a/apps/ignis-server/server/config.js +++ b/apps/ignis-server/server/config.js @@ -103,7 +103,6 @@ module.exports = { const assetsPath = process.env.OBSIDIAN_ASSETS_PATH || path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"); - q; try { const pkg = JSON.parse( fs.readFileSync(path.join(assetsPath, "package.json"), "utf-8"), diff --git a/apps/ignis-server/server/plugins/headless-sync/obsidian/manifest.json b/apps/ignis-server/server/plugins/headless-sync/obsidian/manifest.json index 60ab4c4..1269868 100644 --- a/apps/ignis-server/server/plugins/headless-sync/obsidian/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/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 5425726..69b93a8 100644 --- a/build.js +++ b/build.js @@ -12,6 +12,27 @@ const headlessSyncDir = path.join( "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"), diff --git a/packages/bridge/src/settings/general-tab.js b/packages/bridge/src/settings/general-tab.js index bd50b9c..bfcff57 100644 --- a/packages/bridge/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"); diff --git a/packages/shim/build.js b/packages/shim/build.js index 31f0a75..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")], @@ -18,7 +21,8 @@ module.exports = esbuild.build({ }, 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/loader.js b/packages/shim/src/loader.js index 31a595f..19a1b07 100644 --- a/packages/shim/src/loader.js +++ b/packages/shim/src/loader.js @@ -13,8 +13,8 @@ import { 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); From 7d70872f7e24e85a2c10263604414457884bc00b Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Mon, 1 Jun 2026 16:58:06 +0200 Subject: [PATCH 7/9] css fix for tables on firefox --- apps/ignis-server/server/assets/overrides.css | 6 ++++++ 1 file changed, 6 insertions(+) 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; +} From 5bf120defab5975c37b52f8b8dd0daceeeb875c1 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Mon, 1 Jun 2026 17:52:20 +0200 Subject: [PATCH 8/9] update docs --- README.md | 2 +- apps/ignis-server/README.md | 1 + docs/ARCHITECTURE.md | 52 +++++++++++++++++++++---------------- 3 files changed, 31 insertions(+), 24 deletions(-) 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/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/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 From 3af868703786177f916ef2e4da9a5fb88ab7df71 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Mon, 1 Jun 2026 17:52:33 +0200 Subject: [PATCH 9/9] bump version --- CHANGELOG.md | 11 +++++++++++ package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) 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/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": [