Merge pull request #19 from Nystik-gh/virtual-plugin

0.8.3: Ignis API, virtual plugins, version pipeline
This commit is contained in:
Nystik
2026-06-01 18:14:09 +02:00
committed by GitHub
67 changed files with 1327 additions and 1036 deletions

View File

@@ -11,5 +11,4 @@ demo-vaults
data
tmp
**/dist
packages/bridge-plugin/main.js
apps/ignis-server/server/plugins/*/plugin/main.js
apps/ignis-server/server/build-info.json

3
.gitignore vendored
View File

@@ -3,7 +3,6 @@ dist/
investigation/
vaults/
packages/*/dist/
packages/bridge-plugin/main.js
apps/ignis-server/server/plugins/*/plugin/main.js
apps/ignis-server/server/build-info.json
demo-vaults/
data/

View File

@@ -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

View File

@@ -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.

View File

@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
COPY packages/services/package.json ./packages/services/
COPY packages/shim/package.json ./packages/shim/
COPY packages/ui/package.json ./packages/ui/
COPY packages/bridge-plugin/package.json ./packages/bridge-plugin/
COPY packages/bridge/package.json ./packages/bridge/
COPY packages/server-core/package.json ./packages/server-core/
COPY apps/ignis-server/package.json ./apps/ignis-server/
@@ -38,7 +38,7 @@ COPY package.json package-lock.json ./
COPY packages/services/package.json ./packages/services/
COPY packages/shim/package.json ./packages/shim/
COPY packages/ui/package.json ./packages/ui/
COPY packages/bridge-plugin/package.json ./packages/bridge-plugin/
COPY packages/bridge/package.json ./packages/bridge/
COPY packages/server-core/package.json ./packages/server-core/
COPY apps/ignis-server/package.json ./apps/ignis-server/
@@ -50,15 +50,11 @@ COPY apps/ignis-server/scripts/ ./apps/ignis-server/scripts/
COPY images/ ./images/
COPY packages/server-core/src/ ./packages/server-core/src/
# Bridge plugin: manifest + styles for auto-install into vaults; built main.js comes from the build stage.
COPY packages/bridge-plugin/manifest.json ./packages/bridge-plugin/
COPY packages/bridge-plugin/styles.css ./packages/bridge-plugin/
# Built artifacts from the build stage.
COPY --from=build /app/packages/shim/dist/shim-loader.js ./packages/shim/dist/shim-loader.js
COPY --from=build /app/packages/ui/dist/ignis-ui.js ./packages/ui/dist/ignis-ui.js
COPY --from=build /app/packages/bridge-plugin/main.js ./packages/bridge-plugin/main.js
COPY --from=build /app/apps/ignis-server/server/plugins/headless-sync/plugin/main.js ./apps/ignis-server/server/plugins/headless-sync/plugin/main.js
COPY --from=build /app/apps/ignis-server/server/build-info.json ./apps/ignis-server/server/build-info.json
COPY --from=build /app/apps/ignis-server/server/plugins/headless-sync/obsidian/dist/ ./apps/ignis-server/server/plugins/headless-sync/obsidian/dist/
RUN chmod +x /app/apps/ignis-server/scripts/entrypoint.sh

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -1,46 +1,42 @@
const fs = require("fs");
const path = require("path");
const {
installObsidianPlugin,
isObsidianPluginInstalled,
} = require("./plugin-system/obsidian-plugin");
const BRIDGE_PLUGIN_ID = "ignis-bridge";
const BRIDGE_PLUGIN_DIR = path.join(__dirname, "..", "..", "..", "packages", "bridge-plugin");
// .ignis metadata helpers
// Old vaults still have bridge in .obsidian/plugins from before it became virtual.
async function migratePluginFromVault(vaultPath, vaultName, pluginId) {
let didWork = false;
async function getIgnisMeta(vaultPath) {
const metaFile = path.join(vaultPath, ".ignis", "meta.json");
const pluginDir = path.join(vaultPath, ".obsidian", "plugins", pluginId);
if (await fs.promises.stat(pluginDir).catch(() => null)) {
await fs.promises.rm(pluginDir, { recursive: true, force: true });
didWork = true;
}
const cpFile = path.join(vaultPath, ".obsidian", "community-plugins.json");
try {
const content = await fs.promises.readFile(metaFile, "utf-8");
return JSON.parse(content);
} catch {
return {};
const list = JSON.parse(await fs.promises.readFile(cpFile, "utf-8"));
if (Array.isArray(list)) {
const filtered = list.filter((id) => id !== pluginId);
if (filtered.length !== list.length) {
await fs.promises.writeFile(cpFile, JSON.stringify(filtered));
didWork = true;
}
}
} catch {}
if (didWork) {
console.log(`[ignis] Migrated ${pluginId} out of vault: ${vaultName}`);
}
return didWork;
}
async function setIgnisMeta(vaultPath, data) {
const ignisDir = path.join(vaultPath, ".ignis");
const metaFile = path.join(ignisDir, "meta.json");
await fs.promises.mkdir(ignisDir, { recursive: true });
await fs.promises.writeFile(metaFile, JSON.stringify(data, null, 2));
}
// Bridge plugin install/check
async function isBridgePluginInstalled(vaultPath) {
return isObsidianPluginInstalled(BRIDGE_PLUGIN_ID, vaultPath);
}
async function installBridgePlugin(vaultPath) {
const result = await installObsidianPlugin(BRIDGE_PLUGIN_DIR, vaultPath);
return result.installed;
}
async function updateBridgePluginInAllVaults(vaultRoot) {
async function migratePluginsFromAllVaults(vaultRoot, pluginIds) {
if (!(await fs.promises.stat(vaultRoot).catch(() => null))) {
return;
}
@@ -53,18 +49,14 @@ async function updateBridgePluginInAllVaults(vaultRoot) {
}
const vaultPath = path.join(vaultRoot, entry.name);
const installed = await installBridgePlugin(vaultPath);
if (installed) {
console.log(`[ignis] Installed bridge plugin in vault: ${entry.name}`);
for (const pluginId of pluginIds) {
await migratePluginFromVault(vaultPath, entry.name, pluginId);
}
}
}
module.exports = {
installBridgePlugin,
updateBridgePluginInAllVaults,
isBridgePluginInstalled,
getIgnisMeta,
setIgnisMeta,
BRIDGE_PLUGIN_ID,
migratePluginsFromAllVaults,
};

View File

@@ -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 ||

View File

@@ -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);

View File

@@ -9,8 +9,15 @@ const {
watcher,
writeCoalescer,
} = require("@ignis/server-core");
const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
const {
BRIDGE_PLUGIN_ID,
migratePluginsFromAllVaults,
} = require("./bridge-plugin");
const {
initPlugins,
shutdownPlugins,
getBundledPluginDirs,
} = require("./plugin-system/manager");
const pluginRoutes = require("./routes/plugins");
writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs });
const { flushAll } = writeCoalescer;
@@ -170,14 +177,28 @@ const server = app.listen(config.port, async () => {
console.log(`[ignis] Vault root: ${config.vaultRoot}`);
console.log(`[ignis] Vaults: ${Object.keys(config.vaults).join(", ")}`);
await updateBridgePluginInAllVaults(config.vaultRoot);
await initPlugins({ app, config, wss, watcher });
const bundledPluginDirs = getBundledPluginDirs();
for (const { distDir } of bundledPluginDirs) {
app.use(express.static(distDir));
}
await migratePluginsFromAllVaults(config.vaultRoot, [
BRIDGE_PLUGIN_ID,
...bundledPluginDirs.map((d) => d.bundledPluginId),
]);
bootstrapRoutes
.warmUp()
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
});
const wss = setupWebSocket(server, { getVaultPath: config.getVaultPath });
const wss = setupWebSocket(server, {
getVaultPath: config.getVaultPath,
originAllowlist: config.wsOrigins,
});
wireDemoWebSocket(server);
async function gracefulShutdown(signal) {

View File

@@ -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,
});

View File

@@ -3,10 +3,7 @@ const path = require("path");
const express = require("express");
const { discoverPlugins } = require("./discovery");
const configStore = require("./config-store");
const {
installObsidianPlugin,
removeObsidianPlugin,
} = require("./obsidian-plugin");
const { getVersion } = require("../version");
let discoveredPlugins = new Map();
const loadedPlugins = new Map();
@@ -50,18 +47,6 @@ async function initPlugins(ctx) {
continue;
}
const discovered = discoveredPlugins.get(pluginId);
if (discovered.obsidianPlugin) {
try {
await installObsidianPlugin(discovered.obsidianPlugin, vaultPath);
} catch (e) {
console.error(
`[plugins] Failed to verify bundled plugin for ${pluginId} in ${vaultId}: ${e.message}`,
);
}
}
const loaded = loadedPlugins.get(pluginId);
if (loaded?.module?.onVaultEnabled) {
@@ -182,30 +167,28 @@ async function enablePluginForVault(pluginId, vaultId) {
await loadPlugin(pluginId);
}
if (discovered.obsidianPlugin) {
try {
const result = await installObsidianPlugin(
discovered.obsidianPlugin,
vaultPath,
);
if (result.installed) {
console.log(
`[plugins] Installed bundled Obsidian plugin for ${pluginId} in vault: ${vaultId}`,
);
}
} catch (e) {
console.error(
`[plugins] Failed to install bundled plugin for ${pluginId}: ${e.message}`,
);
}
}
const loaded = loadedPlugins.get(pluginId);
if (loaded?.module?.onVaultEnabled) {
await loaded.module.onVaultEnabled(vaultId, vaultPath);
}
// Broadcast to any open tabs on this vault so they load the plugin properly.
if (discovered.obsidianPlugin && discovered.bundledPluginId) {
const v = `?v=${getVersion()}`;
const entry = {
id: discovered.bundledPluginId,
scriptUrl: `/${discovered.bundledPluginId}.js${v}`,
cssUrl: `/${discovered.bundledPluginId}.css${v}`,
manifest: discovered.bundledManifest,
};
serverCtx.wss?.broadcastToVault?.(vaultId, {
type: "virtual-plugin-enable",
vault: vaultId,
entry,
});
}
}
async function disablePluginForVault(pluginId, vaultId) {
@@ -227,25 +210,6 @@ async function disablePluginForVault(pluginId, vaultId) {
await loaded.module.onVaultDisabled(vaultId, vaultPath);
}
if (discovered.obsidianPlugin) {
try {
const result = await removeObsidianPlugin(
discovered.obsidianPlugin,
vaultPath,
);
if (result.removed) {
console.log(
`[plugins] Removed bundled Obsidian plugin for ${pluginId} from vault: ${vaultId}`,
);
}
} catch (e) {
console.error(
`[plugins] Failed to remove bundled plugin for ${pluginId}: ${e.message}`,
);
}
}
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
const updated = enabledVaults.filter((id) => id !== vaultId);
configStore.setEnabledVaults(pluginConfig, pluginId, updated);
@@ -254,6 +218,55 @@ async function disablePluginForVault(pluginId, vaultId) {
if (updated.length === 0) {
await unloadPlugin(pluginId);
}
if (discovered.bundledPluginId) {
serverCtx.wss?.broadcastToVault?.(vaultId, {
type: "virtual-plugin-disable",
vault: vaultId,
id: discovered.bundledPluginId,
});
}
}
function getBundledPluginDirs() {
const dirs = [];
for (const [, discovered] of discoveredPlugins) {
if (discovered.obsidianPlugin && discovered.bundledPluginId) {
dirs.push({
bundledPluginId: discovered.bundledPluginId,
distDir: path.join(discovered.obsidianPlugin, "dist"),
});
}
}
return dirs;
}
function getVirtualPluginsForVault(vaultId, version) {
const v = version ? `?v=${version}` : "";
const result = [];
for (const [pluginId, discovered] of discoveredPlugins) {
if (!discovered.obsidianPlugin || !discovered.bundledPluginId) {
continue;
}
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
if (!enabledVaults.includes(vaultId)) {
continue;
}
result.push({
id: discovered.bundledPluginId,
scriptUrl: `/${discovered.bundledPluginId}.js${v}`,
cssUrl: `/${discovered.bundledPluginId}.css${v}`,
manifest: discovered.bundledManifest,
});
}
return result;
}
function getDiscoveredPlugins() {
@@ -280,4 +293,6 @@ module.exports = {
enablePluginForVault,
disablePluginForVault,
getDiscoveredPlugins,
getBundledPluginDirs,
getVirtualPluginsForVault,
};

View File

@@ -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 };

View File

@@ -11,7 +11,7 @@ module.exports = {
version: "0.3.0",
//TODO: add server plugin manifest
obsidianPlugin: path.join(__dirname, "plugin"),
obsidianPlugin: path.join(__dirname, "obsidian"),
_ctx: null,
_obStatus: null,
@@ -63,22 +63,9 @@ module.exports = {
const { mountRoutes } = require("./routes");
mountRoutes(ctx.router, this);
// Register WebSocket message handler for log subscriptions
if (ctx.wss && ctx.wss.messageHandlers) {
ctx.wss.messageHandlers.set("subscribe-logs", (msg) => {
if (msg.vaultId && this._broadcaster) {
this._broadcaster.subscribeToLogs(msg.vaultId);
}
});
}
},
async shutdown() {
if (this._ctx?.wss?.messageHandlers) {
this._ctx.wss.messageHandlers.delete("subscribe-logs");
}
if (this._syncManager) {
await this._syncManager.shutdown();
this._syncManager = null;

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;
}
};
}

View File

@@ -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;
}
}
}

View File

@@ -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() {

View File

@@ -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();
};
}

View File

@@ -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 };

View File

@@ -9,8 +9,11 @@ const fsp = fs.promises;
const path = require("path");
const zlib = require("zlib");
const config = require("../config");
const { isBridgePluginInstalled, getIgnisMeta } = require("../bridge-plugin");
const { getDiscoveredPlugins } = require("../plugin-system/manager");
const {
getDiscoveredPlugins,
getVirtualPluginsForVault,
} = require("../plugin-system/manager");
const { getVersion } = require("../version");
const router = express.Router();
@@ -76,20 +79,13 @@ async function walkTree(rootPath) {
return { tree, dirMtimes };
}
async function buildVaultInfo(vaultId, vaultPath) {
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
const ignisMeta = await getIgnisMeta(vaultPath);
function buildVaultInfo(vaultId, vaultPath) {
return {
id: vaultId,
name: vaultId,
path: vaultPath,
platform: process.platform,
version: config.obsidianVersion,
ignisPlugin: {
installed: pluginInstalled,
prompted: ignisMeta.pluginPrompted || false,
},
};
}
@@ -134,10 +130,8 @@ async function buildEntry(vaultId) {
}
const t0 = Date.now();
const [vault, { tree, dirMtimes }] = await Promise.all([
buildVaultInfo(vaultId, vaultPath),
walkTree(vaultPath),
]);
const vault = buildVaultInfo(vaultId, vaultPath);
const { tree, dirMtimes } = await walkTree(vaultPath);
const response = {
vault,
@@ -145,6 +139,7 @@ async function buildEntry(vaultId) {
tree,
// In demo mode, hide server-side plugins from the client.
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()),
};
const jsonBuf = Buffer.from(JSON.stringify(response));

View File

@@ -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;

View File

@@ -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,
});
});

View File

@@ -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 };

View File

@@ -1,6 +1,38 @@
const esbuild = require("esbuild");
const fs = require("fs");
const path = require("path");
const headlessSyncDir = path.join(
__dirname,
"apps",
"ignis-server",
"server",
"plugins",
"headless-sync",
"obsidian",
);
// Compute version info once and share across per-package builds.
const { version: semver } = require("./package.json");
const build = process.env.IGNIS_BUILD || Date.now().toString(36).slice(-7);
const version = `${semver}+${build}`;
const buildInfoPath = path.join(
__dirname,
"apps",
"ignis-server",
"server",
"build-info.json",
);
fs.writeFileSync(
buildInfoPath,
JSON.stringify({ semver, build, version }, null, 2),
);
// Used by packages.
process.env.IGNIS_BUILD_RESOLVED = build;
Promise.all([
// Build shim-loader.js (delegated to packages/shim)
require("./packages/shim/build.js"),
@@ -8,39 +40,22 @@ Promise.all([
// Build ignis-ui.js (delegated to packages/ui)
require("./packages/ui/build.js"),
// Build ignis-bridge plugin (delegated to packages/bridge-plugin)
require("./packages/bridge-plugin/build.js"),
// Build headless-sync bundled plugin
esbuild.build({
entryPoints: [
path.join(
__dirname,
"apps",
"ignis-server",
"server",
"plugins",
"headless-sync",
"plugin",
"src",
"main.js",
),
],
bundle: true,
outfile: path.join(
__dirname,
"apps",
"ignis-server",
"server",
"plugins",
"headless-sync",
"plugin",
"main.js",
),
format: "cjs",
platform: "browser",
target: ["chrome90"],
external: ["obsidian", "fs"], //using fs shim
logLevel: "info",
}),
esbuild
.build({
entryPoints: [path.join(headlessSyncDir, "src", "main.js")],
bundle: true,
outfile: path.join(headlessSyncDir, "dist", "ignis-headless-sync.js"),
format: "cjs",
platform: "browser",
target: ["chrome90"],
external: ["obsidian", "fs"],
logLevel: "info",
})
.then(() => {
fs.copyFileSync(
path.join(headlessSyncDir, "styles.css"),
path.join(headlessSyncDir, "dist", "ignis-headless-sync.css"),
);
}),
]).catch(() => process.exit(1));

View File

@@ -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/<vaultId>/<path>` - 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/<name>/` 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/<name>/` 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/<pluginId>/`.
When enabled, a plugin's Express router is mounted at `/api/ext/<pluginId>/`. 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

16
package-lock.json generated
View File

@@ -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"
}

View File

@@ -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": [

View File

@@ -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",
});

View File

@@ -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
}

View File

@@ -1,11 +0,0 @@
{
"name": "@ignis/bridge-plugin",
"version": "0.0.0-internal",
"private": true,
"scripts": {
"build": "node build.js"
},
"devDependencies": {
"esbuild": "^0.20.0"
}
}

View File

@@ -1,48 +0,0 @@
function getWsStatus() {
const ws = window.__ignisWs;
if (!ws) {
return "disconnected";
}
switch (ws.readyState) {
case WebSocket.CONNECTING:
return "connecting";
case WebSocket.OPEN:
return "connected";
default:
return "disconnected";
}
}
const STATUS_LABELS = {
connected: "Ignis server: Connected",
connecting: "Ignis server: Connecting...",
disconnected: "Ignis server: Disconnected",
};
function initStatusBar(plugin) {
const item = plugin.addStatusBarItem();
item.addClass("ignis-statusbar-item");
const dot = item.createEl("span", {
cls: "ignis-statusbar-dot",
});
item.setAttribute("aria-label", "Ignis: Checking...");
item.setAttribute("data-tooltip-position", "top");
const update = () => {
const status = getWsStatus();
dot.className = `ignis-statusbar-dot ignis-statusbar-${status}`;
item.setAttribute("aria-label", STATUS_LABELS[status] || "Ignis: Unknown");
};
update();
const interval = setInterval(update, 3000);
return interval;
}
module.exports = { initStatusBar };

View File

@@ -0,0 +1,6 @@
{
"name": "@ignis/bridge",
"version": "0.0.0-internal",
"private": true,
"main": "src/main.js"
}

View File

@@ -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) {

View File

@@ -4,13 +4,8 @@ const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
const GITHUB_API_LATEST =
"https://api.github.com/repos/Nystik-gh/ignis/releases/latest";
function getVersion(app) {
try {
const manifest = app.plugins.getPlugin("ignis-bridge")?.manifest;
return manifest?.version || "unknown";
} catch {
return "unknown";
}
function getVersion() {
return window.__ignis?.version || "unknown";
}
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
@@ -41,7 +36,7 @@ async function checkForUpdate(currentVersion) {
}
function display(containerEl, app) {
const version = getVersion(app);
const version = getVersion();
const header = containerEl.createDiv("ignis-header");
@@ -95,64 +90,44 @@ function display(containerEl, app) {
addServerStatus(containerEl);
}
function getWsStatus() {
const ws = window.__ignisWs;
const STATUS_LABELS = {
open: "Connected",
connecting: "Connecting...",
closed: "Disconnected",
};
if (!ws) {
return "disconnected";
}
switch (ws.readyState) {
case WebSocket.CONNECTING:
return "connecting";
case WebSocket.OPEN:
return "connected";
case WebSocket.CLOSING:
case WebSocket.CLOSED:
return "disconnected";
default:
return "disconnected";
}
}
function statusLabel(status) {
switch (status) {
case "connected":
return "Connected";
case "connecting":
return "Connecting...";
case "disconnected":
return "Disconnected";
default:
return "Unknown";
}
}
const STATUS_DOT_CLASSES = {
open: "ignis-status-connected",
connecting: "ignis-status-connecting",
closed: "ignis-status-disconnected",
};
function addServerStatus(containerEl) {
const status = getWsStatus();
const ws = window.__ignis.ws;
const setting = new Setting(containerEl).setName("Server status");
const dotEl = setting.controlEl.createEl("span", {
cls: `ignis-status-dot ignis-status-${status}`,
cls: "ignis-status-dot",
});
const labelEl = setting.controlEl.createEl("span", {
text: statusLabel(status),
cls: "ignis-status-label",
});
const update = () => {
const s = getWsStatus();
dotEl.className = `ignis-status-dot ignis-status-${s}`;
labelEl.textContent = statusLabel(s);
};
function render(state) {
dotEl.className = `ignis-status-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
labelEl.textContent = STATUS_LABELS[state] || STATUS_LABELS.closed;
}
const pollInterval = setInterval(update, 3000);
render(ws.isOpen() ? "open" : "closed");
const unsub = ws.onStateChange(render);
// Detach when the settings tab DOM goes away.
const observer = new MutationObserver(() => {
if (!containerEl.isConnected) {
clearInterval(pollInterval);
unsub();
observer.disconnect();
}
});

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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}`);

View File

@@ -0,0 +1,35 @@
const STATUS_LABELS = {
open: "Ignis server: Connected",
connecting: "Ignis server: Connecting...",
closed: "Ignis server: Disconnected",
};
const STATUS_DOT_CLASSES = {
open: "ignis-statusbar-connected",
connecting: "ignis-statusbar-connecting",
closed: "ignis-statusbar-disconnected",
};
function initStatusBar(plugin) {
const ws = window.__ignis.ws;
const item = plugin.addStatusBarItem();
item.addClass("ignis-statusbar-item");
const dot = item.createEl("span", {
cls: "ignis-statusbar-dot",
});
item.setAttribute("data-tooltip-position", "top");
function render(state) {
dot.className = `ignis-statusbar-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
item.setAttribute("aria-label", STATUS_LABELS[state] || STATUS_LABELS.closed);
}
render(ws.isOpen() ? "open" : "closed");
return ws.onStateChange(render);
}
module.exports = { initStatusBar };

View File

@@ -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<type, handler>.
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);
});
});

View File

@@ -1,7 +1,10 @@
const esbuild = require("esbuild");
const path = require("path");
const { version: ignisVersion } = require("../../package.json");
const { version: semver } = require("../../package.json");
// Root build.js sets IGNIS_BUILD_RESOLVED when it runs first; standalone invocation falls back to a dev stamp.
const build = process.env.IGNIS_BUILD_RESOLVED || "dev";
module.exports = esbuild.build({
entryPoints: [path.join(__dirname, "src", "loader.js")],
@@ -13,8 +16,13 @@ module.exports = esbuild.build({
alias: {
path: "path-browserify",
},
loader: {
".css": "text",
},
external: ["obsidian", "fs"],
define: {
__IGNIS_VERSION__: JSON.stringify(ignisVersion),
__IGNIS_VERSION__: JSON.stringify(semver),
__IGNIS_BUILD__: JSON.stringify(build),
},
logLevel: "info",
});

View File

@@ -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);
}

View File

@@ -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 = {

View File

@@ -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.

View File

@@ -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));
}

View File

@@ -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 {

View File

@@ -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 || {};
}

View File

@@ -1,7 +1,6 @@
import { fsShim } from "./fs/index.js";
import { installRequestUrlShim } from "./request-url.js";
import { vaultService } from "@ignis/services";
import { showPluginInstallDialog } from "./ui-registry.js";
import { registerReadTransform } from "./fs/transforms.js";
import {
resolveWorkspaceName,
@@ -12,6 +11,12 @@ import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
import { initNativeMenuGuard } from "./native-menu-guard.js";
let bootstrapVirtualPlugins = [];
export function getBootstrapVirtualPlugins() {
return bootstrapVirtualPlugins;
}
function resolveVaultId() {
const urlParams = new URLSearchParams(window.location.search);
window.__currentVaultId =
@@ -56,8 +61,6 @@ function applyVaultInfo(info) {
path: "/",
};
window.__ignisPlugin = info.ignisPlugin || null;
console.log("[ignis] Vault:", window.__vaultConfig);
console.log("[ignis] Obsidian version:", window.__obsidianVersion);
}
@@ -124,30 +127,6 @@ function initMetadataCacheFallback() {
}
}
function initPluginPrompt() {
if (
!window.__ignisPlugin ||
window.__ignisPlugin.installed ||
window.__ignisPlugin.prompted
) {
return;
}
const vaultId = window.__currentVaultId;
const observer = new MutationObserver(() => {
if (document.querySelector(".workspace")) {
observer.disconnect();
showPluginInstallDialog(vaultId);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// if headless sync is active, we transform reads of core-plugins.json to hide the sync setting from Obsidian.
// this prevents headless sync from being disabled as a result of a different device syncing "Active core plugins list".
// i.e ensure Ignis always has sync: false if headless sync is active.
@@ -232,6 +211,7 @@ export function initialize() {
autoTrustDemoVaults(bootstrap.vaultList);
applyTree(bootstrap.tree);
applyCoreSyncGuard(bootstrap.plugins);
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
// Race the indexer: batch-fetch text content into ContentCache so
// Obsidian's startup indexing reads hit the cache instead of the network.
@@ -249,5 +229,4 @@ export function initialize() {
installRequestUrlShim();
initWorkspacePatch();
initPluginPrompt();
}

View File

@@ -1,14 +1,36 @@
import { installRequire } from "./require.js";
import { installGlobals } from "./globals.js";
import { installCssOverrides } from "./css-overrides.js";
import { initialize } from "./init.js";
import { initialize, getBootstrapVirtualPlugins } from "./init.js";
import { fsShim } from "./fs/index.js";
import { registerUI } from "./ui-registry.js";
import {
extractObsidianModule,
loadVirtualPlugin,
reportLoadFailure,
watchPluginToggles,
} from "./virtual-plugin-loader.js";
import { wsClient } from "./ws-client.js";
import { installIgnisApi } from "./ignis-api.js";
// __IGNIS_VERSION__ is replaced at build time from package.json.
window.__ignis = { version: __IGNIS_VERSION__ };
// __IGNIS_VERSION__ (semver) and __IGNIS_BUILD__ are replaced at build time.
window.__ignis = { version: __IGNIS_VERSION__, build: __IGNIS_BUILD__ };
window.__ignis_registerUI = registerUI;
installIgnisApi(wsClient);
const BRIDGE_MANIFEST = {
id: "ignis-bridge",
name: "Ignis Bridge",
version: __IGNIS_VERSION__,
minAppVersion: "1.12.4",
description:
"Additional Ignis specific functionality and ignis plugin management.",
author: "Nystik",
authorUrl: "https://github.com/Nystik-gh/ignis",
isDesktopOnly: false,
};
installGlobals(); // process, Buffer, window overrides (before require so Buffer is available)
installRequire(); // shim registry, window.require
installCssOverrides(); // browser-specific CSS fixes
@@ -22,9 +44,30 @@ if (window.innerWidth < 600) {
initialize(); // vault config, metadata cache, plugin prompt
// Connect file watcher WebSocket after everything is initialized
// Connect the shared WebSocket after everything is initialized; watcher and live-toggle subscribers attach to the same client.
if (window.__currentVaultId) {
fsShim._watcherClient.connect(window.__currentVaultId);
watchPluginToggles(wsClient);
}
extractObsidianModule()
.then(async () => {
// Dynamic import so bridge's top-level require("obsidian") fires after installRequire + extractObsidianModule.
const mod = await import("@ignis/bridge");
const IgnisBridgePlugin = mod.default || mod;
const bridge = new IgnisBridgePlugin(window.app, BRIDGE_MANIFEST);
await bridge.onload();
console.log("[ignis] bridge loaded");
for (const vp of getBootstrapVirtualPlugins()) {
try {
await loadVirtualPlugin(vp);
console.log(`[ignis] virtual plugin loaded: ${vp.id}`);
} catch (e) {
reportLoadFailure(vp.id, e);
}
}
})
.catch((e) => console.error("[ignis] bridge load failed:", e));
console.log("[ignis] Shim loader initialized");

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -0,0 +1,229 @@
// Capture the obsidian module via a one-shot synthetic plugin so virtual plugins (bridge, future bundled) can require("obsidian").
import { setVirtualFile, removeVirtualFile } from "./fs/virtual-files.js";
import { registerShim } from "./require.js";
const EXTRACTOR_ID = "ignis-obsidian-extractor";
const EXTRACTOR_DIR = ".ignis/virtual/" + EXTRACTOR_ID;
const EXTRACTOR_PATH = EXTRACTOR_DIR + "/main.js";
const EXTRACTOR_SRC = `
const obsidian = require("obsidian");
window.__ignisCapturedObsidian = obsidian;
module.exports = class extends obsidian.Plugin {
onload() {}
};
`;
const EXTRACTOR_MANIFEST = {
id: EXTRACTOR_ID,
name: "Ignis Obsidian Module Extractor",
version: "0.0.0",
minAppVersion: "1.0.0",
description: "Internal: captures the obsidian module for virtual plugins.",
author: "ignis",
authorUrl: "",
isDesktopOnly: false,
dir: EXTRACTOR_DIR,
};
function waitForApp() {
return new Promise((resolve) => {
if (window.app && window.app.plugins && window.app.workspace) {
return resolve();
}
const interval = setInterval(() => {
if (window.app && window.app.plugins && window.app.workspace) {
clearInterval(interval);
resolve();
}
}, 20);
});
}
export async function extractObsidianModule() {
if (window.__ignis.obsidian) {
return window.__ignis.obsidian;
}
await waitForApp();
const plugins = window.app.plugins;
// loadPlugin gates on isEnabled(). Force-enable, restore on cleanup.
const wasEnabled = plugins.isEnabled();
let toggledOn = false;
if (!wasEnabled) {
try {
await plugins.setEnable(true);
toggledOn = true;
} catch (e) {
console.warn(
"[ignis] could not enable community plugins for extractor:",
e,
);
}
}
setVirtualFile(EXTRACTOR_PATH, EXTRACTOR_SRC);
plugins.manifests[EXTRACTOR_ID] = EXTRACTOR_MANIFEST;
try {
await plugins.loadPlugin(EXTRACTOR_ID);
} catch (e) {
console.error("[ignis] extractor load failed:", e);
}
const captured = window.__ignisCapturedObsidian;
try {
await plugins.unloadPlugin(EXTRACTOR_ID);
} catch {}
delete plugins.manifests[EXTRACTOR_ID];
removeVirtualFile(EXTRACTOR_PATH);
delete window.__ignisCapturedObsidian;
if (toggledOn) {
try {
await plugins.setEnable(false);
} catch {}
}
if (!captured) {
console.error("[ignis] obsidian module extraction failed");
return null;
}
window.__ignis.obsidian = captured;
registerShim("obsidian", captured);
console.log("[ignis] obsidian module captured");
return captured;
}
// Serialize per-id load/unload so rapid toggles can't race.
const inFlight = new Map();
function serialized(id, fn) {
const prev = inFlight.get(id) || Promise.resolve();
const next = prev.then(fn, fn);
inFlight.set(id, next);
next.finally(() => {
if (inFlight.get(id) === next) {
inFlight.delete(id);
}
});
return next;
}
export function loadVirtualPlugin(entry) {
return serialized(entry.id, async () => {
window.__ignis.plugins = window.__ignis.plugins || {};
if (window.__ignis.plugins[entry.id]) {
console.log(`[ignis] virtual plugin already loaded: ${entry.id}`);
return;
}
if (entry.cssUrl) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = entry.cssUrl;
link.setAttribute("data-ignis-virtual-plugin", entry.id);
document.head.appendChild(link);
}
const res = await fetch(entry.scriptUrl);
if (!res.ok) {
throw new Error(
`fetch ${entry.scriptUrl} -> ${res.status} ${res.statusText}`,
);
}
const src =
(await res.text()) + `\n//# sourceURL=ignis-virtual/${entry.id}.js`;
const module = { exports: {} };
const localRequire = (name) =>
name === "obsidian" ? window.__ignis.obsidian : window.require(name);
new Function("module", "exports", "require", src)(
module,
module.exports,
localRequire,
);
const PluginClass = module.exports.default || module.exports;
const instance = new PluginClass(window.app, entry.manifest);
// _loaded = true makes instance.unload() walk the Plugin's _register list later.
// Cleans up addCommand / addStatusBarItem / addRibbonIcon / addSettingTab / registerEvent.
instance._loaded = true;
await instance.onload();
window.__ignis.plugins[entry.id] = { instance, manifest: entry.manifest };
});
}
export function unloadVirtualPlugin(id) {
return serialized(id, async () => {
const tracked = window.__ignis?.plugins?.[id];
if (!tracked) {
return;
}
try {
await tracked.instance.unload();
} catch (e) {
reportUnloadFailure(id, e);
}
document
.querySelectorAll(`link[data-ignis-virtual-plugin="${id}"]`)
.forEach((el) => el.remove());
delete window.__ignis.plugins[id];
});
}
//TODO: move to ignis API object?
function notice(text) {
try {
new window.__ignis.obsidian.Notice(text);
} catch {}
}
export function reportLoadFailure(id, e) {
console.error(`[ignis] virtual plugin load failed: ${id}`, e);
notice(`Failed to load plugin '${id}': ${e.message}`);
}
export function reportUnloadFailure(id, e) {
console.warn(`[ignis] virtual plugin unload failed: ${id}`, e);
notice(`Failed to unload plugin '${id}': ${e.message}`);
}
export function watchPluginToggles(wsClient) {
wsClient.subscribe("virtual-plugin-enable", (msg) => {
if (msg.vault !== window.__currentVaultId) {
return;
}
loadVirtualPlugin(msg.entry).catch((e) =>
reportLoadFailure(msg.entry?.id, e),
);
});
wsClient.subscribe("virtual-plugin-disable", (msg) => {
if (msg.vault !== window.__currentVaultId) {
return;
}
unloadVirtualPlugin(msg.id).catch((e) => reportUnloadFailure(msg.id, e));
});
}

View File

@@ -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<handler>
const channelSubs = new Map(); // channelName -> Map<type, Set<handler>>
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();

View File

@@ -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") {

View File

@@ -1,89 +0,0 @@
<script>
import { createEventDispatcher } from "svelte";
import Modal from "./Modal.svelte";
import Button from "../input/Button.svelte";
import { Puzzle, Download, X } from "lucide-svelte";
export let width = "500px";
const dispatch = createEventDispatcher();
let modalRef;
let installing = false;
function onInstall() {
installing = true;
dispatch("install");
}
function onDismiss() {
modalRef.dismiss();
dispatch("dismiss");
}
function onEscape() {
onDismiss();
}
export function dismiss() {
modalRef.dismiss();
}
</script>
<Modal title="Ignis Bridge Plugin" {width} bind:this={modalRef} on:escape={onEscape} closeOnOverlayClick={false}>
<svelte:fragment slot="icon">
<Puzzle size="1.25rem" />
</svelte:fragment>
<div class="dialog-body">
<p class="dialog-message">This vault doesn't have the Ignis Bridge plugin installed.</p>
<p class="dialog-description">
The plugin adds additional functionality such as file uploads.
Obsidian will work without it, but some features will be unavailable.
</p>
</div>
<svelte:fragment slot="footer">
<div class="dialog-footer">
<Button variant="secondary" on:click={onDismiss}>
<svelte:fragment slot="icon">
<X size="0.875rem" />
</svelte:fragment>
Not Now
</Button>
<Button variant="primary" on:click={onInstall} disabled={installing}>
<svelte:fragment slot="icon">
<Download size="0.875rem" />
</svelte:fragment>
{installing ? "Installing..." : "Install Plugin"}
</Button>
</div>
</svelte:fragment>
</Modal>
<style>
.dialog-body {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.dialog-message {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-normal);
}
.dialog-description {
margin: 0;
font-size: 0.875rem;
color: var(--text-muted);
line-height: 1.5;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>

View File

@@ -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";