expose Ignis API, implement shared ws client

This commit is contained in:
Nystik 2026-05-24 21:51:02 +02:00
parent 9eeff3c1b3
commit 28effab1ed
29 changed files with 824 additions and 745 deletions

View file

@ -50,14 +50,9 @@ COPY apps/ignis-server/scripts/ ./apps/ignis-server/scripts/
COPY images/ ./images/ COPY images/ ./images/
COPY packages/server-core/src/ ./packages/server-core/src/ COPY packages/server-core/src/ ./packages/server-core/src/
# Bridge plugin: manifest + styles for auto-install into vaults; built main.js comes from the build stage.
COPY packages/bridge/manifest.json ./packages/bridge/
COPY packages/bridge/styles.css ./packages/bridge/
# Built artifacts from the build stage. # 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/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/ui/dist/ignis-ui.js ./packages/ui/dist/ignis-ui.js
COPY --from=build /app/packages/bridge/main.js ./packages/bridge/main.js
COPY --from=build /app/apps/ignis-server/server/plugins/headless-sync/obsidian/dist/ ./apps/ignis-server/server/plugins/headless-sync/obsidian/dist/ 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 RUN chmod +x /app/apps/ignis-server/scripts/entrypoint.sh

View file

@ -5,8 +5,7 @@ const REPO_ROOT = path.join(__dirname, "..", "..", "..");
// VAULT_ROOT: a directory that contains vault folders. // VAULT_ROOT: a directory that contains vault folders.
// Each subdirectory is a vault. New vaults are created as new subdirs. // Each subdirectory is a vault. New vaults are created as new subdirs.
const vaultRoot = const vaultRoot = process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults");
process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults");
const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data"); const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data");
@ -81,6 +80,12 @@ module.exports = {
? parseInt(process.env.WRITE_COALESCE_MS) ? parseInt(process.env.WRITE_COALESCE_MS)
: 5000, : 5000,
wsOrigins: process.env.WS_ORIGINS
? process.env.WS_ORIGINS.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null,
demoMode: process.env.DEMO_MODE === "true", demoMode: process.env.DEMO_MODE === "true",
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20, demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
demoVaultsPerSession: parseInt(process.env.DEMO_VAULTS_PER_SESSION) || 3, demoVaultsPerSession: parseInt(process.env.DEMO_VAULTS_PER_SESSION) || 3,
@ -88,8 +93,7 @@ module.exports = {
parseInt(process.env.DEMO_SESSION_QUOTA_BYTES) || 700 * 1024, parseInt(process.env.DEMO_SESSION_QUOTA_BYTES) || 700 * 1024,
demoTimeoutMs: parseInt(process.env.DEMO_TIMEOUT_MS) || 30 * 60 * 1000, demoTimeoutMs: parseInt(process.env.DEMO_TIMEOUT_MS) || 30 * 60 * 1000,
demoTemplateDir: demoTemplateDir:
process.env.DEMO_TEMPLATE_DIR || process.env.DEMO_TEMPLATE_DIR || path.join(__dirname, "demo-template"),
path.join(__dirname, "demo-template"),
obsidianAssetsPath: obsidianAssetsPath:
process.env.OBSIDIAN_ASSETS_PATH || process.env.OBSIDIAN_ASSETS_PATH ||
@ -99,6 +103,7 @@ module.exports = {
const assetsPath = const assetsPath =
process.env.OBSIDIAN_ASSETS_PATH || process.env.OBSIDIAN_ASSETS_PATH ||
path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"); path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked");
q;
try { try {
const pkg = JSON.parse( const pkg = JSON.parse(
fs.readFileSync(path.join(assetsPath, "package.json"), "utf-8"), fs.readFileSync(path.join(assetsPath, "package.json"), "utf-8"),

View file

@ -195,7 +195,10 @@ const server = app.listen(config.port, async () => {
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message)); .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); wireDemoWebSocket(server);
async function gracefulShutdown(signal) { async function gracefulShutdown(signal) {

View file

@ -3,6 +3,7 @@ const path = require("path");
const express = require("express"); const express = require("express");
const { discoverPlugins } = require("./discovery"); const { discoverPlugins } = require("./discovery");
const configStore = require("./config-store"); const configStore = require("./config-store");
const { getVersion } = require("../version");
let discoveredPlugins = new Map(); let discoveredPlugins = new Map();
const loadedPlugins = new Map(); const loadedPlugins = new Map();
@ -171,6 +172,23 @@ async function enablePluginForVault(pluginId, vaultId) {
if (loaded?.module?.onVaultEnabled) { if (loaded?.module?.onVaultEnabled) {
await loaded.module.onVaultEnabled(vaultId, vaultPath); 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) { async function disablePluginForVault(pluginId, vaultId) {
@ -200,6 +218,14 @@ async function disablePluginForVault(pluginId, vaultId) {
if (updated.length === 0) { if (updated.length === 0) {
await unloadPlugin(pluginId); await unloadPlugin(pluginId);
} }
if (discovered.bundledPluginId) {
serverCtx.wss?.broadcastToVault?.(vaultId, {
type: "virtual-plugin-disable",
vault: vaultId,
id: discovered.bundledPluginId,
});
}
} }
function getBundledPluginDirs() { function getBundledPluginDirs() {

View file

@ -2,57 +2,26 @@ const CHANNEL = "plugin:headless-sync";
class SyncBroadcaster { class SyncBroadcaster {
constructor(wss) { constructor(wss) {
this._wss = wss; this._channel = wss.channel(CHANNEL);
this._logSubscriptions = new Map();
}
subscribeToLogs(vaultId) {
this._logSubscriptions.set(vaultId, { expires: Date.now() + 10000 });
} }
broadcastLog(vaultId, line) { broadcastLog(vaultId, line) {
if (!this._wss?.clients) { this._channel.broadcastToVault(vaultId, {
return;
}
const sub = this._logSubscriptions.get(vaultId);
if (!sub || Date.now() > sub.expires) {
return;
}
this._send({
channel: CHANNEL,
type: "sync-log", type: "sync-log",
payload: { vaultId, line }, payload: { vaultId, line },
}); });
} }
broadcastStatus(state) { broadcastStatus(state) {
if (!state) { if (!state || !state.vaultId) {
return; return;
} }
this._send({ this._channel.broadcastToVault(state.vaultId, {
channel: CHANNEL,
type: "sync-status", type: "sync-status",
payload: state, 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 }; module.exports = { SyncBroadcaster };

View file

@ -63,22 +63,9 @@ module.exports = {
const { mountRoutes } = require("./routes"); const { mountRoutes } = require("./routes");
mountRoutes(ctx.router, this); 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() { async shutdown() {
if (this._ctx?.wss?.messageHandlers) {
this._ctx.wss.messageHandlers.delete("subscribe-logs");
}
if (this._syncManager) { if (this._syncManager) {
await this._syncManager.shutdown(); await this._syncManager.shutdown();
this._syncManager = null; this._syncManager = null;

View file

@ -32,13 +32,12 @@ function showConflictWarning(title, message) {
}); });
} }
function startCoreSyncGuard(plugin, api, wsListener) { function startCoreSyncGuard(plugin, api) {
const app = plugin.app; const app = plugin.app;
const vaultId = app.vault.getName(); const vaultId = app.vault.getName();
// Monkey-patch syncPlugin.enable() to clear the shim flag before // Monkey-patch syncPlugin.enable() to clear the shim flag before Obsidian writes core-plugins.json.
// Obsidian writes core-plugins.json. This ensures the read transform // This ensures the read transform doesn't block a user-initiated core sync enable.
// doesn't block a user-initiated core sync enable.
const syncPlugin = app.internalPlugins.getPluginById("sync"); const syncPlugin = app.internalPlugins.getPluginById("sync");
let origEnable = null; let origEnable = null;
@ -52,16 +51,13 @@ function startCoreSyncGuard(plugin, api, wsListener) {
}; };
} }
// Watch for core-plugins.json changes via WebSocket.
let wasEnabled = isCoreSyncEnabled(); let wasEnabled = isCoreSyncEnabled();
const rawHandler = (msg) => { const unsubModified = window.__ignis.ws.subscribe("modified", (msg) => {
if (msg.type === "modified" && msg.path === CORE_PLUGINS_PATH) { if (msg.path === CORE_PLUGINS_PATH) {
handleCoreSyncChange(); handleCoreSyncChange();
} }
}; });
wsListener.onRaw(rawHandler);
function handleCoreSyncChange() { function handleCoreSyncChange() {
const enabled = isCoreSyncEnabled(); const enabled = isCoreSyncEnabled();
@ -80,7 +76,7 @@ function startCoreSyncGuard(plugin, api, wsListener) {
return { return {
cleanup() { cleanup() {
wsListener.offRaw(); unsubModified();
if (syncPlugin && origEnable) { if (syncPlugin && origEnable) {
syncPlugin.enable = origEnable; syncPlugin.enable = origEnable;

View file

@ -1,6 +1,8 @@
const api = require("./api"); 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", { const details = containerEl.createEl("details", {
cls: "ignis-log-details", cls: "ignis-log-details",
}); });
@ -32,19 +34,12 @@ async function renderLogViewer(containerEl, vaultId, wsListener) {
logBox.scrollTop = logBox.scrollHeight; logBox.scrollTop = logBox.scrollHeight;
if (!wsListener) { const channel = window.__ignis.ws.channel(CHANNEL);
return () => {}; let unsubLog = null;
}
details.addEventListener("toggle", () => { const onLog = (msg) => {
if (details.open) { const payload = msg.payload || {};
wsListener.subscribeLogs(vaultId);
} else {
wsListener.unsubscribeLogs();
}
});
const onLog = (payload) => {
if (payload.vaultId !== vaultId) { if (payload.vaultId !== vaultId) {
return; 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 () => { return () => {
wsListener.off("sync-log", onLog); if (unsubLog) {
wsListener.unsubscribeLogs(); unsubLog();
unsubLog = null;
}
}; };
} }

View file

@ -1,6 +1,5 @@
const { Plugin } = require("obsidian"); const { Plugin } = require("obsidian");
const { HeadlessSyncSettingTab } = require("./settings-tab"); const { HeadlessSyncSettingTab } = require("./settings-tab");
const { WsListener } = require("./ws-listener");
const { initSyncStatusBar } = require("./sync-status-bar"); const { initSyncStatusBar } = require("./sync-status-bar");
const { startCoreSyncGuard } = require("./core-sync-guard"); const { startCoreSyncGuard } = require("./core-sync-guard");
const api = require("./api"); const api = require("./api");
@ -14,14 +13,11 @@ class IgnisHeadlessSyncPlugin extends Plugin {
return; return;
} }
this.wsListener = new WsListener(); this._syncStatusBarCleanup = initSyncStatusBar(this);
this.wsListener.start();
this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener);
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this)); this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
this._coreSyncGuard = startCoreSyncGuard(this, api, this.wsListener); this._coreSyncGuard = startCoreSyncGuard(this, api);
this.addCommand({ this.addCommand({
id: "start-sync", id: "start-sync",
@ -75,11 +71,6 @@ class IgnisHeadlessSyncPlugin extends Plugin {
this._syncStatusBarCleanup(); this._syncStatusBarCleanup();
this._syncStatusBarCleanup = null; 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) { async renderLogs(containerEl, vaultId) {
this._logCleanup = await renderLogViewer( this._logCleanup = await renderLogViewer(containerEl, vaultId);
containerEl,
vaultId,
this.plugin.wsListener,
);
} }
hide() { hide() {

View file

@ -1,6 +1,8 @@
const { setIcon } = require("obsidian"); const { setIcon } = require("obsidian");
const api = require("./api"); const api = require("./api");
const CHANNEL = "plugin:headless-sync";
const TOOLTIP_MAP = { const TOOLTIP_MAP = {
running: "Syncing...", running: "Syncing...",
synced: "Synced", synced: "Synced",
@ -8,8 +10,11 @@ const TOOLTIP_MAP = {
error: "Sync error", error: "Sync error",
}; };
function initSyncStatusBar(plugin, wsListener) { function initSyncStatusBar(plugin) {
const vaultId = plugin.app.vault.getName(); const vaultId = plugin.app.vault.getName();
const ws = window.__ignis.ws;
const channel = ws.channel(CHANNEL);
const item = plugin.addStatusBarItem(); const item = plugin.addStatusBarItem();
item.addClass("ignis-sync-statusbar"); item.addClass("ignis-sync-statusbar");
item.style.display = "none"; item.style.display = "none";
@ -21,6 +26,7 @@ function initSyncStatusBar(plugin, wsListener) {
let popoverOpen = false; let popoverOpen = false;
let currentStatus = "stopped"; let currentStatus = "stopped";
let outsideClickHandler = null; let outsideClickHandler = null;
let unsubLog = null;
function updateState(status, error) { function updateState(status, error) {
currentStatus = status; currentStatus = status;
@ -62,7 +68,7 @@ function initSyncStatusBar(plugin, wsListener) {
popoverOpen = true; popoverOpen = true;
wsListener.subscribeLogs(vaultId); unsubLog = channel.subscribe("sync-log", onLog);
outsideClickHandler = (e) => { outsideClickHandler = (e) => {
if (!item.contains(e.target)) { if (!item.contains(e.target)) {
@ -86,7 +92,11 @@ function initSyncStatusBar(plugin, wsListener) {
outsideClickHandler = null; outsideClickHandler = null;
} }
wsListener.unsubscribeLogs(); if (unsubLog) {
unsubLog();
unsubLog = null;
}
popoverOpen = false; popoverOpen = false;
} }
@ -95,7 +105,7 @@ function initSyncStatusBar(plugin, wsListener) {
return path; return path;
} }
return "\u2026" + path.slice(-(maxLen - 1)); return "" + path.slice(-(maxLen - 1));
} }
function formatPopoverText(prefix, path) { function formatPopoverText(prefix, path) {
@ -115,35 +125,30 @@ function initSyncStatusBar(plugin, wsListener) {
} }
function extractFileActivity(line) { function extractFileActivity(line) {
// Downloading/Downloaded path
let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/); let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/);
if (match) { if (match) {
return { prefix: "Syncing", path: match[1].trim() }; return { prefix: "Syncing", path: match[1].trim() };
} }
// Uploading file / Upload complete path
match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/); match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/);
if (match) { if (match) {
return { prefix: "Syncing", path: match[1].trim() }; return { prefix: "Syncing", path: match[1].trim() };
} }
// Deleting path
match = line.match(/^Deleting\s+(.+)$/); match = line.match(/^Deleting\s+(.+)$/);
if (match) { if (match) {
return { prefix: "Deleting", path: match[1].trim() }; return { prefix: "Deleting", path: match[1].trim() };
} }
// Push: path (updated)
match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/); match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/);
if (match) { if (match) {
return { prefix: "Syncing", path: match[1].trim() }; return { prefix: "Syncing", path: match[1].trim() };
} }
// Push: path (deleted)
match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/); match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/);
if (match) { if (match) {
@ -157,7 +162,6 @@ function initSyncStatusBar(plugin, wsListener) {
return /Fully synced/i.test(line); return /Fully synced/i.test(line);
} }
// Click toggles popover
item.addEventListener("click", () => { item.addEventListener("click", () => {
if (popoverOpen) { if (popoverOpen) {
hidePopover(); hidePopover();
@ -166,16 +170,15 @@ function initSyncStatusBar(plugin, wsListener) {
} }
}); });
// Listen for status updates const onStatus = (msg) => {
const onStatus = (payload) => { const payload = msg.payload || {};
if (payload.vaultId !== vaultId) { if (payload.vaultId !== vaultId) {
return; return;
} }
item.style.display = ""; 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") { if (payload.status === "running") {
updateState("synced"); updateState("synced");
} else { } 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; let syncedTimer = null;
function deferSynced() { function deferSynced() {
@ -208,8 +209,9 @@ function initSyncStatusBar(plugin, wsListener) {
} }
} }
// Listen for log lines function onLog(msg) {
const onLog = (payload) => { const payload = msg.payload || {};
if (payload.vaultId !== vaultId) { if (payload.vaultId !== vaultId) {
return; return;
} }
@ -226,11 +228,8 @@ function initSyncStatusBar(plugin, wsListener) {
updateState("running"); updateState("running");
updatePopoverText(formatPopoverText(activity.prefix, activity.path)); updatePopoverText(formatPopoverText(activity.prefix, activity.path));
} }
}; }
wsListener.on("sync-log", onLog);
// Fetch initial state
api api
.getVaults() .getVaults()
.then((data) => { .then((data) => {
@ -244,16 +243,16 @@ function initSyncStatusBar(plugin, wsListener) {
}) })
.catch(() => {}); .catch(() => {});
// Poll WebSocket state to detect server disconnect/reconnect // Reflect WebSocket disconnect/reconnect in the indicator.
let wasDisconnected = false; let wasDisconnected = false;
const wsCheckInterval = setInterval(() => { const unsubState = ws.onStateChange((state) => {
const disconnected = !wsListener.isConnected(); const open = state === "open";
if (disconnected && currentStatus === "running") { if (!open && currentStatus === "running") {
updateState("error", "Server connection lost"); updateState("error", "Server connection lost");
wasDisconnected = true; wasDisconnected = true;
} else if (!disconnected && wasDisconnected) { } else if (open && wasDisconnected) {
wasDisconnected = false; wasDisconnected = false;
api api
@ -268,14 +267,12 @@ function initSyncStatusBar(plugin, wsListener) {
}) })
.catch(() => {}); .catch(() => {});
} }
}, 3000); });
// Return cleanup function
return () => { return () => {
clearInterval(wsCheckInterval);
cancelDeferredSynced(); cancelDeferredSynced();
wsListener.off("sync-status", onStatus); unsubStatus();
wsListener.off("sync-log", onLog); unsubState();
hidePopover(); 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

@ -95,64 +95,44 @@ function display(containerEl, app) {
addServerStatus(containerEl); addServerStatus(containerEl);
} }
function getWsStatus() { const STATUS_LABELS = {
const ws = window.__ignisWs; open: "Connected",
connecting: "Connecting...",
closed: "Disconnected",
};
if (!ws) { const STATUS_DOT_CLASSES = {
return "disconnected"; open: "ignis-status-connected",
} connecting: "ignis-status-connecting",
closed: "ignis-status-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";
}
}
function addServerStatus(containerEl) { function addServerStatus(containerEl) {
const status = getWsStatus(); const ws = window.__ignis.ws;
const setting = new Setting(containerEl).setName("Server status"); const setting = new Setting(containerEl).setName("Server status");
const dotEl = setting.controlEl.createEl("span", { const dotEl = setting.controlEl.createEl("span", {
cls: `ignis-status-dot ignis-status-${status}`, cls: "ignis-status-dot",
}); });
const labelEl = setting.controlEl.createEl("span", { const labelEl = setting.controlEl.createEl("span", {
text: statusLabel(status),
cls: "ignis-status-label", cls: "ignis-status-label",
}); });
const update = () => { function render(state) {
const s = getWsStatus(); dotEl.className = `ignis-status-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
dotEl.className = `ignis-status-dot ignis-status-${s}`; labelEl.textContent = STATUS_LABELS[state] || STATUS_LABELS.closed;
labelEl.textContent = statusLabel(s); }
};
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(() => { const observer = new MutationObserver(() => {
if (!containerEl.isConnected) { if (!containerEl.isConnected) {
clearInterval(pollInterval); unsub();
observer.disconnect(); observer.disconnect();
} }
}); });

View file

@ -2,6 +2,7 @@ const generalTab = require("./general-tab");
const serverPluginsTab = require("./server-plugins-tab"); const serverPluginsTab = require("./server-plugins-tab");
const { createNavEl, createTab, createGroup } = require("./settings-ui"); const { createNavEl, createTab, createGroup } = require("./settings-ui");
const { const {
allIgnisNavEls,
setupPluginTabs, setupPluginTabs,
reconcilePluginTabs, reconcilePluginTabs,
hideIgnisFromCommunityPlugins, 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) { function replaceInstallerVersionRow(setting, ignisVersion) {
const container = setting.tabContentContainer || setting.contentEl; const container = setting.tabContentContainer || setting.contentEl;
@ -117,7 +114,7 @@ function injectIgnisSettings(setting, app, plugin) {
setting.tabHeadersEl.appendChild(corePlugins.group); setting.tabHeadersEl.appendChild(corePlugins.group);
hideIgnisFromCommunityPlugins(setting); hideIgnisFromCommunityPlugins(setting);
setupPluginTabs(setting, corePlugins.items, allIgnisNavEls); setupPluginTabs(setting, corePlugins.items);
} }
function patchSettingsModal(plugin) { function patchSettingsModal(plugin) {
@ -142,7 +139,4 @@ function unpatchSettingsModal(plugin) {
clearOwnedPluginIds(); clearOwnedPluginIds();
} }
window.__ignisReconcilePluginTabs = (setting) =>
reconcilePluginTabs(setting, allIgnisNavEls);
module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs }; module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };

View file

@ -2,10 +2,14 @@ const { setIcon } = require("obsidian");
const { findGroupByTitle } = require("./settings-ui"); const { findGroupByTitle } = require("./settings-ui");
const { isIgnisPlugin } = require("../plugin-registry"); 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. // Tracks which plugin IDs have nav items we created.
const ownedPluginIds = new Set(); const ownedPluginIds = new Set();
function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) { function addPluginNavItem(pluginId, setting, corePluginsItems) {
const tab = setting.pluginTabs.find((t) => t.id === pluginId); const tab = setting.pluginTabs.find((t) => t.id === pluginId);
if (!tab) { if (!tab) {
@ -41,16 +45,16 @@ function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) {
corePluginsItems.appendChild(nav); corePluginsItems.appendChild(nav);
ownedPluginIds.add(pluginId); ownedPluginIds.add(pluginId);
ignisNavEls.set(pluginId, nav); allIgnisNavEls.set(pluginId, nav);
} }
function removePluginNavItem(pluginId, ignisNavEls) { function removePluginNavItem(pluginId) {
const nav = ignisNavEls.get(pluginId); const nav = allIgnisNavEls.get(pluginId);
if (nav && ownedPluginIds.has(pluginId)) { if (nav && ownedPluginIds.has(pluginId)) {
nav.remove(); nav.remove();
ownedPluginIds.delete(pluginId); ownedPluginIds.delete(pluginId);
ignisNavEls.delete(pluginId); allIgnisNavEls.delete(pluginId);
} }
} }
@ -116,11 +120,11 @@ function hideIgnisNavFromCommunityGroup(setting) {
communityGroup.style.display = hasVisible ? "" : "none"; communityGroup.style.display = hasVisible ? "" : "none";
} }
function hideCorePluginsGroupIfEmpty(ignisNavEls) { function hideCorePluginsGroupIfEmpty() {
let hasConnected = false; let hasConnected = false;
for (const id of ownedPluginIds) { for (const id of ownedPluginIds) {
const nav = ignisNavEls.get(id); const nav = allIgnisNavEls.get(id);
if (nav?.isConnected) { if (nav?.isConnected) {
hasConnected = true; hasConnected = true;
@ -140,15 +144,15 @@ function hideCorePluginsGroupIfEmpty(ignisNavEls) {
} }
} }
function setupPluginTabs(setting, corePluginsItems, ignisNavEls) { function setupPluginTabs(setting, corePluginsItems) {
for (const tab of setting.pluginTabs) { for (const tab of setting.pluginTabs) {
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") { if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls); addPluginNavItem(tab.id, setting, corePluginsItems);
} }
} }
hideIgnisNavFromCommunityGroup(setting); hideIgnisNavFromCommunityGroup(setting);
hideCorePluginsGroupIfEmpty(ignisNavEls); hideCorePluginsGroupIfEmpty();
const communityGroup = findGroupByTitle( const communityGroup = findGroupByTitle(
setting.tabHeadersEl, setting.tabHeadersEl,
@ -159,12 +163,12 @@ function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
for (const tab of setting.pluginTabs) { for (const tab of setting.pluginTabs) {
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") { if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls); addPluginNavItem(tab.id, setting, corePluginsItems);
} }
} }
hideIgnisNavFromCommunityGroup(setting); hideIgnisNavFromCommunityGroup(setting);
hideCorePluginsGroupIfEmpty(ignisNavEls); hideCorePluginsGroupIfEmpty();
}); });
observer.observe(communityGroup, { childList: true, subtree: true }); 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( const corePluginsGroup = findGroupByTitle(
setting.tabHeadersEl, setting.tabHeadersEl,
"Ignis Core Plugins", "Ignis Core Plugins",
@ -212,16 +216,16 @@ function reconcilePluginTabs(setting, ignisNavEls) {
for (const id of ownedPluginIds) { for (const id of ownedPluginIds) {
if (!activeIds.has(id)) { if (!activeIds.has(id)) {
removePluginNavItem(id, ignisNavEls); removePluginNavItem(id);
} }
} }
for (const id of activeIds) { for (const id of activeIds) {
addPluginNavItem(id, setting, corePluginsItems, ignisNavEls); addPluginNavItem(id, setting, corePluginsItems);
} }
hideIgnisNavFromCommunityGroup(setting); hideIgnisNavFromCommunityGroup(setting);
hideCorePluginsGroupIfEmpty(ignisNavEls); hideCorePluginsGroupIfEmpty();
} }
function clearOwnedPluginIds() { function clearOwnedPluginIds() {
@ -229,6 +233,7 @@ function clearOwnedPluginIds() {
} }
module.exports = { module.exports = {
allIgnisNavEls,
setupPluginTabs, setupPluginTabs,
reconcilePluginTabs, reconcilePluginTabs,
hideIgnisFromCommunityPlugins, hideIgnisFromCommunityPlugins,

View file

@ -1,18 +1,10 @@
const { Setting, Notice } = require("obsidian"); const { Setting, Notice } = require("obsidian");
const { reconcilePluginTabs } = require("./plugin-tabs");
function getVaultId() { function getVaultId() {
return window.__currentVaultId || ""; 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() { async function fetchPlugins() {
const res = await fetch("/api/plugins"); const res = await fetch("/api/plugins");
@ -23,7 +15,7 @@ async function fetchPlugins() {
return res.json(); return res.json();
} }
async function togglePlugin(pluginId, enable, app) { async function togglePlugin(pluginId, enable) {
const action = enable ? "enable" : "disable"; const action = enable ? "enable" : "disable";
const vaultId = getVaultId(); const vaultId = getVaultId();
@ -41,25 +33,10 @@ async function togglePlugin(pluginId, enable, app) {
return res.json(); 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) { function display(containerEl, app) {
containerEl.createEl("h2", { text: "Ignis Core Plugins" }); containerEl.createEl("h2", { text: "Ignis Core Plugins" });
const descEl = containerEl.createEl("p", { containerEl.createEl("p", {
text: text:
"Ignis plugins extend server functionality and run alongside your vaults. " + "Ignis plugins extend server functionality and run alongside your vaults. " +
"They are separate from Obsidian's built-in plugins.", "They are separate from Obsidian's built-in plugins.",
@ -92,28 +69,16 @@ function display(containerEl, app) {
toggle.setValue(enabled); toggle.setValue(enabled);
toggle.onChange(async (value) => { toggle.onChange(async (value) => {
try { try {
await togglePlugin(plugin.id, value, app); await togglePlugin(plugin.id, value);
if (value && plugin.bundledPluginId) {
await refreshPluginCache(plugin.bundledPluginId);
}
await activateBundledPlugin(
plugin.bundledPluginId,
value,
app,
);
new Notice( new Notice(
`${plugin.name} ${value ? "enabled" : "disabled"} for this vault.`, `${plugin.name} ${value ? "enabled" : "disabled"} for this vault.`,
); );
// Give Obsidian a moment to update its plugin tabs, // The server's WS broadcast drives the actual load/unload via virtual-plugin-loader.
// then reconcile our sidebar groups. // Reconcile the settings sidebar so the new plugin's settings tab gets grouped correctly.
setTimeout(() => { setTimeout(() => {
if (typeof window.__ignisReconcilePluginTabs === "function") { reconcilePluginTabs(app.setting);
window.__ignisReconcilePluginTabs(app.setting);
}
}, 100); }, 100);
} catch (e) { } catch (e) {
new Notice(`Failed: ${e.message}`); new Notice(`Failed: ${e.message}`);

View file

@ -1,27 +1,18 @@
function getWsStatus() {
const ws = window.__ignisWs;
if (!ws) {
return "disconnected";
}
switch (ws.readyState) {
case WebSocket.CONNECTING:
return "connecting";
case WebSocket.OPEN:
return "connected";
default:
return "disconnected";
}
}
const STATUS_LABELS = { const STATUS_LABELS = {
connected: "Ignis server: Connected", open: "Ignis server: Connected",
connecting: "Ignis server: Connecting...", connecting: "Ignis server: Connecting...",
disconnected: "Ignis server: Disconnected", closed: "Ignis server: Disconnected",
};
const STATUS_DOT_CLASSES = {
open: "ignis-statusbar-connected",
connecting: "ignis-statusbar-connecting",
closed: "ignis-statusbar-disconnected",
}; };
function initStatusBar(plugin) { function initStatusBar(plugin) {
const ws = window.__ignis.ws;
const item = plugin.addStatusBarItem(); const item = plugin.addStatusBarItem();
item.addClass("ignis-statusbar-item"); item.addClass("ignis-statusbar-item");
@ -29,20 +20,16 @@ function initStatusBar(plugin) {
cls: "ignis-statusbar-dot", cls: "ignis-statusbar-dot",
}); });
item.setAttribute("aria-label", "Ignis: Checking...");
item.setAttribute("data-tooltip-position", "top"); item.setAttribute("data-tooltip-position", "top");
const update = () => { function render(state) {
const status = getWsStatus(); dot.className = `ignis-statusbar-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
dot.className = `ignis-statusbar-dot ignis-statusbar-${status}`; item.setAttribute("aria-label", STATUS_LABELS[state] || STATUS_LABELS.closed);
item.setAttribute("aria-label", STATUS_LABELS[status] || "Ignis: Unknown"); }
};
update(); render(ws.isOpen() ? "open" : "closed");
const interval = setInterval(update, 3000); return ws.onStateChange(render);
return interval;
} }
module.exports = { initStatusBar }; module.exports = { initStatusBar };

View file

@ -3,18 +3,117 @@ const url = require("url");
const watcher = require("./watcher"); const watcher = require("./watcher");
function setupWebSocket(server, opts = {}) { function setupWebSocket(server, opts = {}) {
const { getVaultPath } = opts; const { getVaultPath, originAllowlist } = opts;
if (typeof getVaultPath !== "function") { if (typeof getVaultPath !== "function") {
throw new Error("setupWebSocket: opts.getVaultPath is required"); 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" }); 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(); 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) => { 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 params = new url.URL(req.url, "http://localhost").searchParams;
const vaultId = params.get("vault"); const vaultId = params.get("vault");
@ -26,10 +125,16 @@ function setupWebSocket(server, opts = {}) {
const vaultPath = getVaultPath(vaultId); const vaultPath = getVaultPath(vaultId);
console.log(`[ws] Client connected to vault: ${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) // Start watching this vault (no-op if already watching)
watcher.startWatching(vaultId, vaultPath); watcher.startWatching(vaultId, vaultPath);
// Per-client listener that forwards events over WebSocket // Per-client listener that forwards file events over WebSocket
const listener = (event) => { const listener = (event) => {
if (ws.readyState === ws.OPEN) { if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(event)); ws.send(JSON.stringify(event));
@ -38,21 +143,68 @@ function setupWebSocket(server, opts = {}) {
watcher.addListener(vaultId, listener); watcher.addListener(vaultId, listener);
// Dispatch incoming messages to registered handlers // Dispatch incoming messages to registered handlers.
ws.on("message", (data) => { ws.on("message", (data) => {
try { let msg;
const msg = JSON.parse(data);
const handler = wss.messageHandlers.get(msg.type);
if (handler) { try {
handler(msg, ws); 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", () => { ws.on("close", () => {
console.log(`[ws] Client disconnected from vault: ${vaultId}`); console.log(`[ws] Client disconnected from vault: ${vaultId}`);
watcher.removeListener(vaultId, listener); 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

@ -8,6 +8,7 @@ import { createWatcherClient } from "./watcher-client.js";
import { createFdOps } from "./fd.js"; import { createFdOps } from "./fd.js";
import { constants } from "./constants.js"; import { constants } from "./constants.js";
import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js"; import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js";
import { wsClient } from "../ws-client.js";
const metadataCache = new MetadataCache(); const metadataCache = new MetadataCache();
const contentCache = new ContentCache(); const contentCache = new ContentCache();
@ -15,7 +16,7 @@ const contentCache = new ContentCache();
const fsPromises = createFsPromises(metadataCache, contentCache, transport); const fsPromises = createFsPromises(metadataCache, contentCache, transport);
const fsSync = createFsSync(metadataCache, contentCache, transport); const fsSync = createFsSync(metadataCache, contentCache, transport);
const fsWatch = createFsWatch(transport); const fsWatch = createFsWatch(transport);
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch); const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient);
const fdOps = createFdOps(metadataCache, contentCache, transport); const fdOps = createFdOps(metadataCache, contentCache, transport);
export const fsShim = { export const fsShim = {

View file

@ -1,143 +1,83 @@
// Client-side WebSocket file watcher. // Bridges WebSocket file events to the fs shim's metadata/content caches and fs.watch listeners.
// Connects to the server's /ws endpoint, receives file change events, // The WebSocket itself is owned by ws-client.js; this module is a consumer.
// updates the metadata/content caches, and dispatches to fs.watch listeners
// so Obsidian's vault picks them up automatically.
import { isRecentLocalOp } from "./echo-guard.js"; 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) { if (!path || isRecentLocalOp(path)) {
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");
return; return;
} }
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; if (stat) {
const url = `${protocol}//${window.location.host}/ws?vault=${encodeURIComponent(vaultId)}`; metadataCache.set(path, {
type: "file",
try { size: stat.size,
ws = new WebSocket(url); mtime: stat.mtime,
window.__ignisWs = ws; ctime: stat.ctime,
} catch (e) { });
console.error("[watcher] Failed to create WebSocket:", e);
scheduleReconnect();
return;
} }
ws.onopen = () => { contentCache.invalidate(path);
console.log("[watcher] Connected to file watcher"); fsWatch._dispatch("created", path);
};
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);
};
} }
function scheduleReconnect() { function handleFolderCreated(msg) {
if (reconnectTimer) return; const { path } = msg;
reconnectTimer = setTimeout(() => { if (!path || isRecentLocalOp(path)) {
reconnectTimer = null; return;
}
if (vaultId) { metadataCache.set(path, { type: "directory" });
console.log("[watcher] Reconnecting..."); fsWatch._dispatch("folder-created", path);
connect(vaultId);
}
}, RECONNECT_DELAY);
} }
function handleEvent(msg) { function handleModified(msg) {
// Skip channel-based plugin messages, those are for other listeners const { path, stat } = msg;
if (msg.channel) {
if (!path || isRecentLocalOp(path)) {
return; 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 function handleDeleted(msg) {
if (isRecentLocalOp(path)) { const { path } = msg;
if (!path || isRecentLocalOp(path)) {
return; return;
} }
switch (type) { metadataCache.delete(path);
case "created": contentCache.invalidate(path);
if (stat) { fsWatch._dispatch("deleted", path);
metadataCache.set(path, { }
type: "file",
size: stat.size,
mtime: stat.mtime,
ctime: stat.ctime,
});
}
contentCache.invalidate(path);
fsWatch._dispatch("created", path);
break;
case "folder-created": wsClient.subscribe("created", handleCreated);
metadataCache.set(path, { type: "directory" }); wsClient.subscribe("folder-created", handleFolderCreated);
fsWatch._dispatch("folder-created", path); wsClient.subscribe("modified", handleModified);
break; wsClient.subscribe("deleted", handleDeleted);
case "modified": function connect(vaultId) {
if (stat) { wsClient.connect(vaultId);
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 disconnect() { function disconnect() {
if (reconnectTimer) { wsClient.disconnect();
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null; // prevent reconnect
ws.close();
ws = null;
}
} }
return { 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 { fsShim } from "./fs/index.js";
import { installRequestUrlShim } from "./request-url.js"; import { installRequestUrlShim } from "./request-url.js";
import { vaultService } from "@ignis/services"; import { vaultService } from "@ignis/services";
import { showPluginInstallDialog } from "./ui-registry.js";
import { registerReadTransform } from "./fs/transforms.js"; import { registerReadTransform } from "./fs/transforms.js";
import { import {
resolveWorkspaceName, resolveWorkspaceName,
@ -12,6 +11,12 @@ import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js"; import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
import { initNativeMenuGuard } from "./native-menu-guard.js"; import { initNativeMenuGuard } from "./native-menu-guard.js";
let bootstrapVirtualPlugins = [];
export function getBootstrapVirtualPlugins() {
return bootstrapVirtualPlugins;
}
function resolveVaultId() { function resolveVaultId() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
window.__currentVaultId = window.__currentVaultId =
@ -56,8 +61,6 @@ function applyVaultInfo(info) {
path: "/", path: "/",
}; };
window.__ignisPlugin = info.ignisPlugin || null;
console.log("[ignis] Vault:", window.__vaultConfig); console.log("[ignis] Vault:", window.__vaultConfig);
console.log("[ignis] Obsidian version:", window.__obsidianVersion); 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. // 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". // 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. // i.e ensure Ignis always has sync: false if headless sync is active.
@ -232,7 +211,7 @@ export function initialize() {
autoTrustDemoVaults(bootstrap.vaultList); autoTrustDemoVaults(bootstrap.vaultList);
applyTree(bootstrap.tree); applyTree(bootstrap.tree);
applyCoreSyncGuard(bootstrap.plugins); applyCoreSyncGuard(bootstrap.plugins);
window.__ignisVirtualPlugins = bootstrap.virtualPlugins || []; bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
// Race the indexer: batch-fetch text content into ContentCache so // Race the indexer: batch-fetch text content into ContentCache so
// Obsidian's startup indexing reads hit the cache instead of the network. // Obsidian's startup indexing reads hit the cache instead of the network.
@ -250,5 +229,4 @@ export function initialize() {
installRequestUrlShim(); installRequestUrlShim();
initWorkspacePatch(); initWorkspacePatch();
initPluginPrompt();
} }

View file

@ -1,18 +1,24 @@
import { installRequire } from "./require.js"; import { installRequire } from "./require.js";
import { installGlobals } from "./globals.js"; import { installGlobals } from "./globals.js";
import { installCssOverrides } from "./css-overrides.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 { fsShim } from "./fs/index.js";
import { registerUI } from "./ui-registry.js"; import { registerUI } from "./ui-registry.js";
import { import {
extractObsidianModule, extractObsidianModule,
loadVirtualPlugin, loadVirtualPlugin,
reportLoadFailure,
watchPluginToggles,
} from "./virtual-plugin-loader.js"; } 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. // __IGNIS_VERSION__ is replaced at build time from package.json.
window.__ignis = { version: __IGNIS_VERSION__ }; window.__ignis = { version: __IGNIS_VERSION__ };
window.__ignis_registerUI = registerUI; window.__ignis_registerUI = registerUI;
installIgnisApi(wsClient);
const BRIDGE_MANIFEST = { const BRIDGE_MANIFEST = {
id: "ignis-bridge", id: "ignis-bridge",
name: "Ignis Bridge", name: "Ignis Bridge",
@ -38,9 +44,10 @@ if (window.innerWidth < 600) {
initialize(); // vault config, metadata cache, plugin prompt 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) { if (window.__currentVaultId) {
fsShim._watcherClient.connect(window.__currentVaultId); fsShim._watcherClient.connect(window.__currentVaultId);
watchPluginToggles(wsClient);
} }
extractObsidianModule() extractObsidianModule()
@ -52,12 +59,12 @@ extractObsidianModule()
await bridge.onload(); await bridge.onload();
console.log("[ignis] bridge loaded"); console.log("[ignis] bridge loaded");
for (const vp of window.__ignisVirtualPlugins || []) { for (const vp of getBootstrapVirtualPlugins()) {
try { try {
await loadVirtualPlugin(vp); await loadVirtualPlugin(vp);
console.log(`[ignis] virtual plugin loaded: ${vp.id}`); console.log(`[ignis] virtual plugin loaded: ${vp.id}`);
} catch (e) { } catch (e) {
console.error(`[ignis] virtual plugin load failed: ${vp.id}`, e); reportLoadFailure(vp.id, e);
} }
} }
}) })

View file

@ -22,5 +22,4 @@ function proxy(name) {
export const showVaultManager = proxy("showVaultManager"); export const showVaultManager = proxy("showVaultManager");
export const showMessageDialog = proxy("showMessageDialog"); export const showMessageDialog = proxy("showMessageDialog");
export const showConfirmDialog = proxy("showConfirmDialog"); export const showConfirmDialog = proxy("showConfirmDialog");
export const showPluginInstallDialog = proxy("showPluginInstallDialog");
export const showPromptDialog = proxy("showPromptDialog"); export const showPromptDialog = proxy("showPromptDialog");

View file

@ -43,8 +43,8 @@ function waitForApp() {
} }
export async function extractObsidianModule() { export async function extractObsidianModule() {
if (window.__obsidian) { if (window.__ignis.obsidian) {
return window.__obsidian; return window.__ignis.obsidian;
} }
await waitForApp(); await waitForApp();
@ -97,48 +97,133 @@ export async function extractObsidianModule() {
return null; return null;
} }
window.__obsidian = captured; window.__ignis.obsidian = captured;
registerShim("obsidian", captured); registerShim("obsidian", captured);
console.log("[ignis] obsidian module captured"); console.log("[ignis] obsidian module captured");
return captured; return captured;
} }
export async function loadVirtualPlugin(entry) { // Serialize per-id load/unload so rapid toggles can't race.
if (entry.cssUrl) { const inFlight = new Map();
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); function serialized(id, fn) {
const prev = inFlight.get(id) || Promise.resolve();
if (!res.ok) { const next = prev.then(fn, fn);
throw new Error( inFlight.set(id, next);
`fetch ${entry.scriptUrl} -> ${res.status} ${res.statusText}`, next.finally(() => {
); if (inFlight.get(id) === next) {
} inFlight.delete(id);
}
const src = });
(await res.text()) + `\n//# sourceURL=ignis-virtual/${entry.id}.js`; return next;
}
const module = { exports: {} };
const localRequire = (name) => export function loadVirtualPlugin(entry) {
name === "obsidian" ? window.__obsidian : window.require(name); return serialized(entry.id, async () => {
window.__ignis.plugins = window.__ignis.plugins || {};
new Function("module", "exports", "require", src)(
module, if (window.__ignis.plugins[entry.id]) {
module.exports, console.log(`[ignis] virtual plugin already loaded: ${entry.id}`);
localRequire, return;
); }
const PluginClass = module.exports.default || module.exports; if (entry.cssUrl) {
const instance = new PluginClass(window.app, entry.manifest); const link = document.createElement("link");
link.rel = "stylesheet";
await instance.onload(); link.href = entry.cssUrl;
link.setAttribute("data-ignis-virtual-plugin", entry.id);
window.__ignis.plugins = window.__ignis.plugins || {}; document.head.appendChild(link);
window.__ignis.plugins[entry.id] = { instance, manifest: entry.manifest }; }
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( function showPromptDialog(
title, title,
label, label,
@ -113,7 +77,6 @@ if (typeof window !== "undefined" && window.__ignis_registerUI) {
showVaultManager, showVaultManager,
showMessageDialog, showMessageDialog,
showConfirmDialog, showConfirmDialog,
showPluginInstallDialog,
showPromptDialog, showPromptDialog,
}); });
} else if (typeof window !== "undefined") { } 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 MessageDialog } from "./components/layout/MessageDialog.svelte";
export { default as ConfirmDialog } from "./components/layout/ConfirmDialog.svelte"; export { default as ConfirmDialog } from "./components/layout/ConfirmDialog.svelte";
export { default as PromptDialog } from "./components/layout/PromptDialog.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"; export { default as SyncSetupModal } from "./views/SyncSetupModal.svelte";