expose Ignis API, implement shared ws client
This commit is contained in:
parent
9eeff3c1b3
commit
28effab1ed
29 changed files with 824 additions and 745 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
28
packages/shim/src/ignis-api.js
Normal file
28
packages/shim/src/ignis-api.js
Normal 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 || {};
|
||||||
|
}
|
||||||
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
267
packages/shim/src/ws-client.js
Normal file
267
packages/shim/src/ws-client.js
Normal 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();
|
||||||
37
packages/ui/src/bootstrap.js
vendored
37
packages/ui/src/bootstrap.js
vendored
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue