From 300e25173493e9458b781d1d12d93fd176d4a4c0 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Mon, 30 Mar 2026 21:05:47 +0200 Subject: [PATCH] refactor headless sync --- build.js | 2 +- plugin/src/settings/inject.js | 47 +++- plugin/src/settings/plugin-tabs.js | 99 ++++---- server/plugins/headless-sync/broadcaster.js | 58 +++++ server/plugins/headless-sync/index.js | 18 +- .../headless-sync/plugin/manifest.json | 2 +- .../plugin/src/core-sync-guard.js | 69 +++++ .../headless-sync/plugin/src/log-viewer.js | 77 ++++++ .../plugins/headless-sync/plugin/src/main.js | 8 + .../headless-sync/plugin/src/settings-tab.js | 240 ++++++++++-------- .../plugin/src/sync-status-bar.js | 13 +- .../headless-sync/plugin/src/ws-listener.js | 61 +++++ server/plugins/headless-sync/sync-manager.js | 133 ++++++---- server/ws.js | 15 ++ 14 files changed, 619 insertions(+), 223 deletions(-) create mode 100644 server/plugins/headless-sync/broadcaster.js create mode 100644 server/plugins/headless-sync/plugin/src/core-sync-guard.js create mode 100644 server/plugins/headless-sync/plugin/src/log-viewer.js diff --git a/build.js b/build.js index 276c7ad..d95dc1d 100644 --- a/build.js +++ b/build.js @@ -69,7 +69,7 @@ Promise.all([ format: "cjs", platform: "browser", target: ["chrome90"], - external: ["obsidian"], + external: ["obsidian", "fs"], //using fs shim logLevel: "info", }), ]).catch(() => process.exit(1)); diff --git a/plugin/src/settings/inject.js b/plugin/src/settings/inject.js index d20e0dc..df06f02 100644 --- a/plugin/src/settings/inject.js +++ b/plugin/src/settings/inject.js @@ -6,7 +6,7 @@ const { reconcilePluginTabs, hideIgnisFromCommunityPlugins, restoreCommunityPlugins, - clearOwnedNavItems, + clearOwnedPluginIds, } = require("./plugin-tabs"); function removeExistingIgnisGroups(tabHeadersEl) { @@ -24,9 +24,42 @@ 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 patchOpenTab(setting) { + if (setting._ignisOpenTabPatched) { + return; + } + + const original = setting.openTab.bind(setting); + + setting.openTab = function (tab) { + // Clear is-active from all ignis nav items. + for (const [, el] of allIgnisNavEls) { + el.removeClass("is-active"); + } + + original(tab); + + // If the opened tab is one of ours, highlight it. + const navEl = allIgnisNavEls.get(tab.id); + + if (navEl) { + navEl.addClass("is-active"); + } + }; + + setting._ignisOpenTabPatched = true; +} + function injectIgnisSettings(setting, app) { removeExistingIgnisGroups(setting.tabHeadersEl); - clearOwnedNavItems(); + clearOwnedPluginIds(); + allIgnisNavEls.clear(); + + patchOpenTab(setting); const ignis = createGroup("Ignis"); @@ -44,6 +77,7 @@ function injectIgnisSettings(setting, app) { for (const tab of tabs) { tab.navEl = createNavEl(tab, setting); ignis.items.appendChild(tab.navEl); + allIgnisNavEls.set(tab.id, tab.navEl); } setting.tabHeadersEl.appendChild(ignis.group); @@ -52,7 +86,7 @@ function injectIgnisSettings(setting, app) { setting.tabHeadersEl.appendChild(corePlugins.group); hideIgnisFromCommunityPlugins(setting); - setupPluginTabs(setting, corePlugins.items); + setupPluginTabs(setting, corePlugins.items, allIgnisNavEls); } function patchSettingsModal(plugin) { @@ -71,10 +105,13 @@ function unpatchSettingsModal(plugin) { plugin.app.setting.onOpen = plugin._originalOnOpen; } + delete plugin.app.setting._ignisOpenTabPatched; + restoreCommunityPlugins(plugin.app.setting); - clearOwnedNavItems(); + clearOwnedPluginIds(); } -window.__ignisReconcilePluginTabs = reconcilePluginTabs; +window.__ignisReconcilePluginTabs = (setting) => + reconcilePluginTabs(setting, allIgnisNavEls); module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs }; diff --git a/plugin/src/settings/plugin-tabs.js b/plugin/src/settings/plugin-tabs.js index b4b13bf..1d7bea7 100644 --- a/plugin/src/settings/plugin-tabs.js +++ b/plugin/src/settings/plugin-tabs.js @@ -2,23 +2,20 @@ const { setIcon } = require("obsidian"); const { findGroupByTitle } = require("./settings-ui"); const { isIgnisPlugin } = require("../plugin-registry"); -// Tracks our own nav items in the "Ignis Core Plugins" group, keyed by plugin ID. -const ownedNavItems = new Map(); +// Tracks which plugin IDs have nav items we created. +const ownedPluginIds = new Set(); -function addPluginNavItem(pluginId, setting, corePluginsItems) { - // Find the tab object Obsidian created for this plugin. +function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) { const tab = setting.pluginTabs.find((t) => t.id === pluginId); if (!tab) { return; } - // Don't add if we already have one for this ID. - if (ownedNavItems.has(pluginId)) { + if (ownedPluginIds.has(pluginId)) { return; } - // Create our own nav item that delegates to Obsidian's tab. const nav = document.createElement("div"); nav.className = "vertical-tab-nav-item tappable"; @@ -43,15 +40,17 @@ function addPluginNavItem(pluginId, setting, corePluginsItems) { }); corePluginsItems.appendChild(nav); - ownedNavItems.set(pluginId, nav); + ownedPluginIds.add(pluginId); + ignisNavEls.set(pluginId, nav); } -function removePluginNavItem(pluginId) { - const nav = ownedNavItems.get(pluginId); +function removePluginNavItem(pluginId, ignisNavEls) { + const nav = ignisNavEls.get(pluginId); - if (nav) { + if (nav && ownedPluginIds.has(pluginId)) { nav.remove(); - ownedNavItems.delete(pluginId); + ownedPluginIds.delete(pluginId); + ignisNavEls.delete(pluginId); } } @@ -104,14 +103,12 @@ function hideIgnisNavFromCommunityGroup(setting) { return; } - // Hide any ignis plugin nav items that Obsidian placed here. for (const tab of setting.pluginTabs) { if (isIgnisPlugin(tab.id) && tab.navEl?.parentElement === items) { tab.navEl.style.display = "none"; } } - // Hide the entire group if no visible items remain. const hasVisible = Array.from(items.children).some( (el) => el.style.display !== "none", ); @@ -119,45 +116,40 @@ function hideIgnisNavFromCommunityGroup(setting) { communityGroup.style.display = hasVisible ? "" : "none"; } -function hideCorePluginsGroupIfEmpty() { - for (const [, nav] of ownedNavItems) { - if (nav.isConnected) { - const group = nav.closest(".vertical-tab-header-group"); +function hideCorePluginsGroupIfEmpty(ignisNavEls) { + let hasConnected = false; - if (group) { - group.style.display = ""; - } + for (const id of ownedPluginIds) { + const nav = ignisNavEls.get(id); - return; + if (nav?.isConnected) { + hasConnected = true; + break; } } - // No connected items -- find and hide the group. const groups = document.querySelectorAll(".vertical-tab-header-group"); for (const g of groups) { const title = g.querySelector(".vertical-tab-header-group-title"); if (title?.textContent === "Ignis Core Plugins") { - g.style.display = ownedNavItems.size > 0 ? "" : "none"; + g.style.display = hasConnected ? "" : "none"; break; } } } -function setupPluginTabs(setting, corePluginsItems) { - // Create our own nav items for ignis plugin tabs. +function setupPluginTabs(setting, corePluginsItems, ignisNavEls) { for (const tab of setting.pluginTabs) { if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") { - addPluginNavItem(tab.id, setting, corePluginsItems); + addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls); } } hideIgnisNavFromCommunityGroup(setting); - hideCorePluginsGroupIfEmpty(); + hideCorePluginsGroupIfEmpty(ignisNavEls); - // Watch the community group for changes. When Obsidian adds new ignis - // plugin nav items (async after enable), hide them and create our own. const communityGroup = findGroupByTitle( setting.tabHeadersEl, "Community plugins", @@ -167,32 +159,34 @@ function setupPluginTabs(setting, corePluginsItems) { const observer = new MutationObserver(() => { for (const tab of setting.pluginTabs) { if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") { - addPluginNavItem(tab.id, setting, corePluginsItems); + addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls); } } - // Re-evaluate visibility since non-ignis items may have appeared. hideIgnisNavFromCommunityGroup(setting); - hideCorePluginsGroupIfEmpty(); + hideCorePluginsGroupIfEmpty(ignisNavEls); }); observer.observe(communityGroup, { childList: true, subtree: true }); - const cleanupObserver = new MutationObserver(() => { - if (!setting.tabHeadersEl.isConnected) { - observer.disconnect(); - cleanupObserver.disconnect(); - } - }); + const modalEl = setting.tabHeadersEl.closest(".modal"); - cleanupObserver.observe(document.body, { - childList: true, - subtree: true, - }); + if (modalEl && modalEl.parentElement) { + const cleanupObserver = new MutationObserver(() => { + if (!setting.tabHeadersEl.isConnected) { + observer.disconnect(); + cleanupObserver.disconnect(); + } + }); + + cleanupObserver.observe(modalEl.parentElement, { + childList: true, + }); + } } } -function reconcilePluginTabs(setting) { +function reconcilePluginTabs(setting, ignisNavEls) { const corePluginsGroup = findGroupByTitle( setting.tabHeadersEl, "Ignis Core Plugins", @@ -210,31 +204,28 @@ function reconcilePluginTabs(setting) { return; } - // Get current set of ignis plugin IDs from pluginTabs. const activeIds = new Set( setting.pluginTabs .filter((t) => isIgnisPlugin(t.id) && t.id !== "ignis-bridge") .map((t) => t.id), ); - // Remove nav items for plugins that are no longer active. - for (const [id] of ownedNavItems) { + for (const id of ownedPluginIds) { if (!activeIds.has(id)) { - removePluginNavItem(id); + removePluginNavItem(id, ignisNavEls); } } - // Add nav items for newly active plugins. for (const id of activeIds) { - addPluginNavItem(id, setting, corePluginsItems); + addPluginNavItem(id, setting, corePluginsItems, ignisNavEls); } hideIgnisNavFromCommunityGroup(setting); - hideCorePluginsGroupIfEmpty(); + hideCorePluginsGroupIfEmpty(ignisNavEls); } -function clearOwnedNavItems() { - ownedNavItems.clear(); +function clearOwnedPluginIds() { + ownedPluginIds.clear(); } module.exports = { @@ -242,5 +233,5 @@ module.exports = { reconcilePluginTabs, hideIgnisFromCommunityPlugins, restoreCommunityPlugins, - clearOwnedNavItems, + clearOwnedPluginIds, }; diff --git a/server/plugins/headless-sync/broadcaster.js b/server/plugins/headless-sync/broadcaster.js new file mode 100644 index 0000000..de2e307 --- /dev/null +++ b/server/plugins/headless-sync/broadcaster.js @@ -0,0 +1,58 @@ +const CHANNEL = "plugin:headless-sync"; + +class SyncBroadcaster { + constructor(wss) { + this._wss = wss; + this._logSubscriptions = new Map(); + } + + subscribeToLogs(vaultId) { + this._logSubscriptions.set(vaultId, { expires: Date.now() + 10000 }); + } + + broadcastLog(vaultId, line) { + if (!this._wss?.clients) { + return; + } + + const sub = this._logSubscriptions.get(vaultId); + + if (!sub || Date.now() > sub.expires) { + return; + } + + this._send({ + channel: CHANNEL, + type: "sync-log", + payload: { vaultId, line }, + }); + } + + broadcastStatus(state) { + if (!state) { + return; + } + + this._send({ + channel: CHANNEL, + type: "sync-status", + payload: state, + }); + } + + _send(msg) { + if (!this._wss?.clients) { + return; + } + + const data = JSON.stringify(msg); + + for (const client of this._wss.clients) { + if (client.readyState === 1) { + client.send(data); + } + } + } +} + +module.exports = { SyncBroadcaster }; diff --git a/server/plugins/headless-sync/index.js b/server/plugins/headless-sync/index.js index 147f7f9..e601a0d 100644 --- a/server/plugins/headless-sync/index.js +++ b/server/plugins/headless-sync/index.js @@ -2,6 +2,7 @@ const path = require("path"); const obCli = require("./ob-cli"); const auth = require("./auth"); const { SyncManager } = require("./sync-manager"); +const { SyncBroadcaster } = require("./broadcaster"); module.exports = { id: "headless-sync", @@ -15,6 +16,7 @@ module.exports = { _ctx: null, _obStatus: null, _syncManager: null, + _broadcaster: null, async register(ctx) { this._ctx = ctx; @@ -33,7 +35,8 @@ module.exports = { ctx.log("Auth token loaded"); } - this._syncManager = new SyncManager(ctx); + this._broadcaster = new SyncBroadcaster(ctx.wss); + this._syncManager = new SyncManager(ctx, this._broadcaster); // Load saved sync states for enabled vaults const enabledVaults = ctx.getEnabledVaults(); @@ -56,9 +59,22 @@ module.exports = { const { mountRoutes } = require("./routes"); mountRoutes(ctx.router, this); + + // Register WebSocket message handler for log subscriptions + if (ctx.wss && ctx.wss.messageHandlers) { + ctx.wss.messageHandlers.set("subscribe-logs", (msg) => { + if (msg.vaultId && this._broadcaster) { + this._broadcaster.subscribeToLogs(msg.vaultId); + } + }); + } }, async shutdown() { + if (this._ctx?.wss?.messageHandlers) { + this._ctx.wss.messageHandlers.delete("subscribe-logs"); + } + if (this._syncManager) { await this._syncManager.shutdown(); this._syncManager = null; diff --git a/server/plugins/headless-sync/plugin/manifest.json b/server/plugins/headless-sync/plugin/manifest.json index 85624e7..5809e05 100644 --- a/server/plugins/headless-sync/plugin/manifest.json +++ b/server/plugins/headless-sync/plugin/manifest.json @@ -1,7 +1,7 @@ { "id": "ignis-headless-sync", "name": "Ignis Headless Sync", - "version": "0.1.0", + "version": "0.2.0", "minAppVersion": "1.12.4", "description": "Client-side companion for server-side Obsidian Sync", "author": "Ignis", diff --git a/server/plugins/headless-sync/plugin/src/core-sync-guard.js b/server/plugins/headless-sync/plugin/src/core-sync-guard.js new file mode 100644 index 0000000..adbf0e8 --- /dev/null +++ b/server/plugins/headless-sync/plugin/src/core-sync-guard.js @@ -0,0 +1,69 @@ +const { Notice } = require("obsidian"); +const fs = require("fs"); // Using fs shim + +function isCoreSyncEnabled() { + try { + const data = fs.readFileSync(".obsidian/core-plugins.json", "utf-8"); + const config = JSON.parse(data); + return config.sync === true; + } catch { + return false; + } +} + +function showConflictWarning(title, message) { + if (!window.IgnisUI?.MessageDialog) { + new Notice(`${title}: ${message}`, 10000); + return; + } + + const dialog = new window.IgnisUI.MessageDialog({ + target: document.body, + props: { title, message }, + }); + + dialog.$on("confirm", () => { + dialog.$destroy(); + }); +} + +function startCoreSyncWatcher(plugin, api, wsListener) { + let wasEnabled = isCoreSyncEnabled(); + + const rawHandler = (msg) => { + if (msg.type === "modified" && msg.path === ".obsidian/core-plugins.json") { + handleCoreSyncChange(); + } + }; + + wsListener.onRaw(rawHandler); + + function handleCoreSyncChange() { + const enabled = isCoreSyncEnabled(); + + if (enabled && !wasEnabled) { + const vaultId = plugin.app.vault.getName(); + + api.stopSync(vaultId).catch(() => {}); + showConflictWarning( + "Headless Sync Stopped", + "Obsidian Sync has been enabled. Headless Sync has been automatically " + + "stopped to avoid conflicts between the two sync methods.\n\n" + + "To use Headless Sync again, disable Obsidian Sync in Core Plugins.", + ); + } + + wasEnabled = enabled; + } + + return { + cleanup() { + wsListener.offRaw(); + }, + }; +} + +module.exports = { + isCoreSyncEnabled, + startCoreSyncWatcher, +}; diff --git a/server/plugins/headless-sync/plugin/src/log-viewer.js b/server/plugins/headless-sync/plugin/src/log-viewer.js new file mode 100644 index 0000000..45373f7 --- /dev/null +++ b/server/plugins/headless-sync/plugin/src/log-viewer.js @@ -0,0 +1,77 @@ +const api = require("./api"); + +async function renderLogViewer(containerEl, vaultId, wsListener) { + const details = containerEl.createEl("details", { + cls: "ignis-log-details", + }); + + details.createEl("summary", { text: "Sync logs" }); + + const logBox = details.createEl("pre", { cls: "ignis-log-terminal" }); + const codeEl = logBox.createEl("code"); + + let logsData; + + try { + logsData = await api.getLogs(vaultId, 50); + } catch (e) { + codeEl.textContent = `Failed to load logs: ${e.message}`; + return () => {}; + } + + if (logsData.logs.length === 0) { + codeEl.textContent = "No log entries yet."; + } else { + const lines = logsData.logs.map((entry) => { + const time = new Date(entry.timestamp).toLocaleTimeString(); + return `[${time}] ${entry.line}`; + }); + + codeEl.textContent = lines.join("\n"); + } + + logBox.scrollTop = logBox.scrollHeight; + + if (!wsListener) { + return () => {}; + } + + details.addEventListener("toggle", () => { + if (details.open) { + wsListener.subscribeLogs(vaultId); + } else { + wsListener.unsubscribeLogs(); + } + }); + + const onLog = (payload) => { + if (payload.vaultId !== vaultId) { + return; + } + + const time = new Date().toLocaleTimeString(); + const line = `[${time}] ${payload.line}`; + + if (codeEl.textContent === "No log entries yet.") { + codeEl.textContent = line; + } else { + codeEl.textContent += "\n" + line; + } + + const isNearBottom = + logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 50; + + if (isNearBottom) { + logBox.scrollTop = logBox.scrollHeight; + } + }; + + wsListener.on("sync-log", onLog); + + return () => { + wsListener.off("sync-log", onLog); + wsListener.unsubscribeLogs(); + }; +} + +module.exports = { renderLogViewer }; diff --git a/server/plugins/headless-sync/plugin/src/main.js b/server/plugins/headless-sync/plugin/src/main.js index 5d7f8e3..6d6957e 100644 --- a/server/plugins/headless-sync/plugin/src/main.js +++ b/server/plugins/headless-sync/plugin/src/main.js @@ -2,6 +2,7 @@ const { Plugin } = require("obsidian"); const { HeadlessSyncSettingTab } = require("./settings-tab"); const { WsListener } = require("./ws-listener"); const { initSyncStatusBar } = require("./sync-status-bar"); +const { startCoreSyncWatcher } = require("./core-sync-guard"); const api = require("./api"); class IgnisHeadlessSyncPlugin extends Plugin { @@ -13,6 +14,8 @@ class IgnisHeadlessSyncPlugin extends Plugin { this.addSettingTab(new HeadlessSyncSettingTab(this.app, this)); + this._coreSyncWatcher = startCoreSyncWatcher(this, api, this.wsListener); + this.addCommand({ id: "start-sync", name: "Start server-side sync", @@ -50,6 +53,11 @@ class IgnisHeadlessSyncPlugin extends Plugin { } onunload() { + if (this._coreSyncWatcher) { + this._coreSyncWatcher.cleanup(); + this._coreSyncWatcher = null; + } + if (this._syncStatusBarCleanup) { this._syncStatusBarCleanup(); this._syncStatusBarCleanup = null; diff --git a/server/plugins/headless-sync/plugin/src/settings-tab.js b/server/plugins/headless-sync/plugin/src/settings-tab.js index fbcbed8..c6d0f96 100644 --- a/server/plugins/headless-sync/plugin/src/settings-tab.js +++ b/server/plugins/headless-sync/plugin/src/settings-tab.js @@ -1,17 +1,53 @@ const { PluginSettingTab, Setting, Notice } = require("obsidian"); const api = require("./api"); const auth = require("./auth"); +const { isCoreSyncEnabled } = require("./core-sync-guard"); +const { renderLogViewer } = require("./log-viewer"); class HeadlessSyncSettingTab extends PluginSettingTab { constructor(app, plugin) { super(app, plugin); this._cancelWait = null; + this._logCleanup = null; + + // Persistent container refs + this._authEl = null; + this._syncEl = null; + this._logsEl = null; + this._logsRendered = false; } async display() { + // Clean up previous log listener before rebuilding + if (this._logCleanup) { + this._logCleanup(); + this._logCleanup = null; + } + const { containerEl } = this; containerEl.empty(); + this._logsRendered = false; + + if (isCoreSyncEnabled()) { + const syncWarningSetting = new Setting(containerEl) + .setName("Obsidian Sync is active"); + + syncWarningSetting.descEl.createEl("span", { + text: "Headless Sync cannot run alongside Obsidian's built-in sync to avoid conflicts. Disable Obsidian Sync in Core Plugins to use Headless Sync instead.", + cls: "mod-warning", + }); + + syncWarningSetting + .addButton((btn) => { + btn.setButtonText("Open Core Plugins").onClick(() => { + this.app.setting.openTabById("plugins"); + }); + }); + + return; + } + let serverStatus; try { @@ -32,16 +68,21 @@ class HeadlessSyncSettingTab extends PluginSettingTab { return; } - this.renderAuthSection(containerEl, serverStatus); - await this.renderSyncSection(containerEl, serverStatus.authenticated); + this._authEl = containerEl.createDiv(); + this._syncEl = containerEl.createDiv(); + this._logsEl = containerEl.createDiv(); + + this.renderAuthSection(serverStatus); + await this.renderSyncSection(serverStatus.authenticated); } - renderAuthSection(containerEl, serverStatus) { + renderAuthSection(serverStatus) { + this._authEl.empty(); + const localToken = auth.getObsidianSyncToken(); if (serverStatus.authenticated) { - // State C: connected to server - new Setting(containerEl) + new Setting(this._authEl) .setName("Obsidian Sync account") .setDesc( `Signed in as ${serverStatus.name || "unknown"} (${serverStatus.email || "unknown"})`, @@ -53,15 +94,16 @@ class HeadlessSyncSettingTab extends PluginSettingTab { try { await api.logout(); new Notice("Disconnected from Headless Sync"); - this.display(); + const status = await api.getStatus(); + this.renderAuthSection(status); + await this.renderSyncSection(status.authenticated); } catch (e) { new Notice(`Failed to disconnect: ${e.message}`); } }); }); } else if (localToken) { - // State B: signed into Obsidian, not connected to server - new Setting(containerEl) + new Setting(this._authEl) .setName("Obsidian Sync account detected") .setDesc(`${localToken.name} (${localToken.email})`) .addButton((btn) => { @@ -72,44 +114,51 @@ class HeadlessSyncSettingTab extends PluginSettingTab { try { await auth.sendTokenToServer(localToken); new Notice("Connected to Headless Sync"); - this.display(); + const status = await api.getStatus(); + this.renderAuthSection(status); + await this.renderSyncSection(status.authenticated); } catch (e) { new Notice(`Failed to connect: ${e.message}`); } }); }); } else { - // State A: not signed into Obsidian - new Setting(containerEl) + new Setting(this._authEl) .setName("Obsidian Sync account") .setDesc("Sign in to your Obsidian account to enable sync.") .addButton((btn) => { btn.setButtonText("Log in to Obsidian Sync").onClick(() => { - const triggered = auth.triggerLogin(this.app); + const triggered = auth.triggerLogin(this.app); - if (!triggered) { - new Notice("Could not open login dialog. Try logging in from Settings > General."); - return; + if (!triggered) { + new Notice( + "Could not open login dialog. Try logging in from Settings > General.", + ); + return; + } + + this._cancelWait = auth.waitForLogin(async (token) => { + this._cancelWait = null; + + if (token) { + new Notice(`Detected login: ${token.name}`); + const status = await api.getStatus(); + this.renderAuthSection(status); + await this.renderSyncSection(status.authenticated); } - - this._cancelWait = auth.waitForLogin((token) => { - this._cancelWait = null; - - if (token) { - new Notice(`Detected login: ${token.name}`); - this.display(); - } - }); }); + }); }); } } - async renderSyncSection(containerEl, authenticated) { - containerEl.createEl("h3", { text: "Vault sync" }); + async renderSyncSection(authenticated) { + this._syncEl.empty(); + + this._syncEl.createEl("h3", { text: "Vault sync" }); if (!authenticated) { - new Setting(containerEl) + new Setting(this._syncEl) .setName("Sync not configured") .setDesc("Sign in to your Obsidian Sync account to set up sync.") .addButton((btn) => { @@ -127,7 +176,7 @@ class HeadlessSyncSettingTab extends PluginSettingTab { try { vaultsData = await api.getVaults(); } catch (e) { - containerEl.createEl("p", { + this._syncEl.createEl("p", { text: `Failed to load sync state: ${e.message}`, cls: "mod-warning", }); @@ -137,7 +186,7 @@ class HeadlessSyncSettingTab extends PluginSettingTab { const vaultState = vaultsData.vaults.find((v) => v.vaultId === vaultId); if (!vaultState) { - new Setting(containerEl) + new Setting(this._syncEl) .setName("Sync not configured") .setDesc("This vault has not been linked to a remote vault yet.") .addButton((btn) => { @@ -157,10 +206,10 @@ class HeadlessSyncSettingTab extends PluginSettingTab { target: document.body, props: { vaultId, - onSuccess: () => { + onSuccess: async () => { cleanup(); modal.$destroy(); - this.display(); + await this.renderSyncSection(true); }, }, }); @@ -176,9 +225,11 @@ class HeadlessSyncSettingTab extends PluginSettingTab { } // Show current sync config - new Setting(containerEl) + new Setting(this._syncEl) .setName("Remote vault") - .setDesc(vaultState.remoteVaultName || vaultState.remoteVault || "unknown") + .setDesc( + vaultState.remoteVaultName || vaultState.remoteVault || "unknown", + ) .addButton((btn) => { btn.setButtonText("Unlink"); btn.buttonEl.addClass("mod-destructive"); @@ -186,18 +237,44 @@ class HeadlessSyncSettingTab extends PluginSettingTab { try { await api.unlinkVault(vaultId); new Notice("Vault unlinked"); - this.display(); + await this.renderSyncSection(true); } catch (e) { new Notice(`Failed to unlink: ${e.message}`); } }); }); - new Setting(containerEl) + new Setting(this._syncEl) .setName("Sync mode") .setDesc(vaultState.config?.mode || "bidirectional"); // Sync controls + const controlsEl = this._syncEl.createDiv(); + this.renderSyncControls(controlsEl, vaultId, vaultState); + + // Log viewer - only render once, persists across sync section rebuilds + if (!this._logsRendered) { + await this.renderLogs(this._logsEl, vaultId); + this._logsRendered = true; + } + } + + async renderSyncControls(containerEl, vaultId, vaultState) { + containerEl.empty(); + + if (!vaultState) { + try { + const data = await api.getVaults(); + vaultState = (data.vaults || []).find((v) => v.vaultId === vaultId); + } catch { + return; + } + } + + if (!vaultState) { + return; + } + const statusText = vaultState.status === "running" ? "Sync is running" @@ -216,91 +293,34 @@ class HeadlessSyncSettingTab extends PluginSettingTab { try { await api.stopSync(vaultId); new Notice("Sync stopped"); - this.display(); + this.renderSyncControls(containerEl, vaultId); } catch (e) { new Notice(`Failed to stop: ${e.message}`); } }); } else { - btn.setButtonText("Start sync").setCta().onClick(async () => { - try { - await api.startSync(vaultId); - new Notice("Sync started"); - this.display(); - } catch (e) { - new Notice(`Failed to start: ${e.message}`); - } - }); + btn + .setButtonText("Start sync") + .setCta() + .onClick(async () => { + try { + await api.startSync(vaultId); + new Notice("Sync started"); + this.renderSyncControls(containerEl, vaultId); + } catch (e) { + new Notice(`Failed to start: ${e.message}`); + } + }); } }); - - // Log viewer (collapsible) - await this.renderLogs(containerEl, vaultId); } async renderLogs(containerEl, vaultId) { - const details = containerEl.createEl("details", { - cls: "ignis-log-details", - }); - - details.createEl("summary", { text: "Sync logs" }); - - const logBox = details.createEl("pre", { cls: "ignis-log-terminal" }); - const codeEl = logBox.createEl("code"); - - let logsData; - - try { - logsData = await api.getLogs(vaultId, 50); - } catch (e) { - codeEl.textContent = `Failed to load logs: ${e.message}`; - return; - } - - if (logsData.logs.length === 0) { - codeEl.textContent = "No log entries yet."; - } else { - const lines = logsData.logs.map((entry) => { - const time = new Date(entry.timestamp).toLocaleTimeString(); - return `[${time}] ${entry.line}`; - }); - - codeEl.textContent = lines.join("\n"); - } - - logBox.scrollTop = logBox.scrollHeight; - - // Live updates via WebSocket - const wsListener = this.plugin.wsListener; - - if (!wsListener) { - return; - } - - const onLog = (payload) => { - if (payload.vaultId !== vaultId) { - return; - } - - const time = new Date().toLocaleTimeString(); - const line = `[${time}] ${payload.line}`; - - if (codeEl.textContent === "No log entries yet.") { - codeEl.textContent = line; - } else { - codeEl.textContent += "\n" + line; - } - - const isNearBottom = - logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 50; - - if (isNearBottom) { - logBox.scrollTop = logBox.scrollHeight; - } - }; - - wsListener.on("sync-log", onLog); - this._logCleanup = () => wsListener.off("sync-log", onLog); + this._logCleanup = await renderLogViewer( + containerEl, + vaultId, + this.plugin.wsListener, + ); } hide() { diff --git a/server/plugins/headless-sync/plugin/src/sync-status-bar.js b/server/plugins/headless-sync/plugin/src/sync-status-bar.js index ba97a8c..2c300c6 100644 --- a/server/plugins/headless-sync/plugin/src/sync-status-bar.js +++ b/server/plugins/headless-sync/plugin/src/sync-status-bar.js @@ -62,6 +62,8 @@ function initSyncStatusBar(plugin, wsListener) { popoverOpen = true; + wsListener.subscribeLogs(vaultId); + outsideClickHandler = (e) => { if (!item.contains(e.target)) { hidePopover(); @@ -84,6 +86,7 @@ function initSyncStatusBar(plugin, wsListener) { outsideClickHandler = null; } + wsListener.unsubscribeLogs(); popoverOpen = false; } @@ -119,6 +122,13 @@ function initSyncStatusBar(plugin, wsListener) { return { prefix: "Syncing", path: match[1].trim() }; } + // Uploading file / Upload complete path + match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/); + + if (match) { + return { prefix: "Syncing", path: match[1].trim() }; + } + // Deleting path match = line.match(/^Deleting\s+(.+)$/); @@ -238,8 +248,7 @@ function initSyncStatusBar(plugin, wsListener) { let wasDisconnected = false; const wsCheckInterval = setInterval(() => { - const ws = window.__ignisWs; - const disconnected = !ws || ws.readyState !== WebSocket.OPEN; + const disconnected = !wsListener.isConnected(); if (disconnected && currentStatus === "running") { updateState("error", "Server connection lost"); diff --git a/server/plugins/headless-sync/plugin/src/ws-listener.js b/server/plugins/headless-sync/plugin/src/ws-listener.js index e7e102a..2ed0264 100644 --- a/server/plugins/headless-sync/plugin/src/ws-listener.js +++ b/server/plugins/headless-sync/plugin/src/ws-listener.js @@ -1,12 +1,16 @@ 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() { @@ -23,9 +27,15 @@ class WsListener { 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, []); @@ -48,6 +58,52 @@ class WsListener { } } + // 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; @@ -62,6 +118,11 @@ class WsListener { 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; } diff --git a/server/plugins/headless-sync/sync-manager.js b/server/plugins/headless-sync/sync-manager.js index 836da66..8182283 100644 --- a/server/plugins/headless-sync/sync-manager.js +++ b/server/plugins/headless-sync/sync-manager.js @@ -1,12 +1,26 @@ const fs = require("fs"); const path = require("path"); +const { spawn } = require("child_process"); const { spawnOb, runCommand } = require("./ob-cli"); const MAX_LOG_ENTRIES = 200; +function killProcess(proc) { + if (!proc) { + return; + } + + if (process.platform === "win32") { + spawn("taskkill", ["/pid", String(proc.pid), "/t", "/f"]); + } else { + proc.kill("SIGTERM"); + } +} + class SyncManager { - constructor(ctx) { + constructor(ctx, broadcaster) { this.ctx = ctx; + this.broadcaster = broadcaster; this.states = new Map(); this.stateFile = path.join(ctx.dataDir, "sync-states.json"); } @@ -66,8 +80,6 @@ class SyncManager { } async setupSync(vaultId, vaultPath, remoteVault, options = {}) { - const obCli = require("./ob-cli"); - const args = ["sync-setup", "--vault", remoteVault, "--path", "."]; if (options.vaultPassword) { @@ -78,7 +90,7 @@ class SyncManager { args.push("--device-name", options.deviceName); } - await obCli.runCommand(args, { cwd: vaultPath }); + await runCommand(args, { cwd: vaultPath }); const state = { vaultId, @@ -112,6 +124,12 @@ class SyncManager { throw new Error(`No sync configuration for vault: ${vaultId}`); } + if (this.isCoreSyncEnabled(state.vaultPath)) { + const msg = `Cannot start sync for ${vaultId}: Obsidian Sync core plugin is enabled`; + this.ctx.log(msg); + throw new Error(msg); + } + if (state.status === "running") { this.ctx.log(`Sync already running for ${vaultId}`); return this.getState(vaultId); @@ -142,7 +160,7 @@ class SyncManager { if (line.trim()) { this.addLog(state, line.trim()); state.lastActivity = new Date().toISOString(); - this.broadcastLog(vaultId, line.trim()); + this.broadcaster.broadcastLog(vaultId, line.trim()); } } }); @@ -158,6 +176,13 @@ class SyncManager { }); proc.on("close", (code) => { + // If the user explicitly stopped sync, don't overwrite the clean + // "stopped" state with an error from the non-zero exit code. + if (state._userStopped) { + state._userStopped = false; + return; + } + state.status = code === 0 ? "stopped" : "error"; state.pid = null; state._process = null; @@ -170,7 +195,7 @@ class SyncManager { } this.ctx.log(`Sync stopped for ${vaultId} (code: ${code})`); - this.broadcastStatus(vaultId); + this.broadcaster.broadcastStatus(this.getState(vaultId)); this.saveStates(); }); @@ -182,11 +207,11 @@ class SyncManager { this.addLog(state, `Error: ${err.message}`); this.ctx.log(`Sync error for ${vaultId}: ${err.message}`); - this.broadcastStatus(vaultId); + this.broadcaster.broadcastStatus(this.getState(vaultId)); this.saveStates(); }); - this.broadcastStatus(vaultId); + this.broadcaster.broadcastStatus(this.getState(vaultId)); this.ctx.log(`Started sync for ${vaultId} (pid: ${proc.pid})`); this.saveStates(); @@ -200,7 +225,8 @@ class SyncManager { throw new Error(`No active sync for vault: ${vaultId}`); } - state._process.kill("SIGTERM"); + state._userStopped = true; + killProcess(state._process); state.status = "stopped"; state.pid = null; state.autoStart = false; @@ -208,7 +234,7 @@ class SyncManager { this.addLog(state, "Sync stopped by user"); this.ctx.log(`Stopped sync for ${vaultId}`); - this.broadcastStatus(vaultId); + this.broadcaster.broadcastStatus(this.getState(vaultId)); this.saveStates(); return this.getState(vaultId); @@ -222,7 +248,8 @@ class SyncManager { } if (state._process) { - state._process.kill("SIGTERM"); + state._userStopped = true; + killProcess(state._process); } // Tell ob to disconnect from the remote vault and clear its stored config @@ -289,51 +316,31 @@ class SyncManager { } } - broadcastLog(vaultId, line) { - if (!this.ctx.wss || !this.ctx.wss.clients) { - return; - } - - const message = JSON.stringify({ - channel: "plugin:headless-sync", - type: "sync-log", - payload: { vaultId, line }, - }); - - for (const client of this.ctx.wss.clients) { - if (client.readyState === 1) { - client.send(message); - } - } - } - - broadcastStatus(vaultId) { - const state = this.getState(vaultId); - - if (!state) { - return; - } - - const message = JSON.stringify({ - channel: "plugin:headless-sync", - type: "sync-status", - payload: state, - }); - - if (this.ctx.wss && this.ctx.wss.clients) { - for (const client of this.ctx.wss.clients) { - if (client.readyState === 1) { - client.send(message); - } - } + isCoreSyncEnabled(vaultPath) { + try { + const configPath = path.join(vaultPath, ".obsidian", "core-plugins.json"); + const data = fs.readFileSync(configPath, "utf-8"); + const config = JSON.parse(data); + return config.sync === true; + } catch { + return false; } } autoStartAll() { let started = 0; + let skipped = 0; for (const [vaultId, state] of this.states) { if (state.autoStart && state.status === "stopped") { + if (this.isCoreSyncEnabled(state.vaultPath)) { + this.ctx.log( + `Skipping auto-start for ${vaultId}: Obsidian Sync core plugin is enabled`, + ); + skipped++; + continue; + } + try { this.startSync(vaultId); started++; @@ -346,22 +353,50 @@ class SyncManager { if (started > 0) { this.ctx.log(`Auto-started sync for ${started} vault(s)`); } + + if (skipped > 0) { + this.ctx.log( + `Skipped ${skipped} vault(s) due to Obsidian Sync being enabled`, + ); + } } async shutdown() { this.ctx.log("Shutting down sync manager..."); + const waitPromises = []; + for (const [vaultId, state] of this.states) { if (state._process) { this.ctx.log(`Stopping sync for ${vaultId}...`); + state._userStopped = true; + + const proc = state._process; + + waitPromises.push( + new Promise((resolve) => { + const timeout = setTimeout(resolve, 5000); + + proc.on("close", () => { + clearTimeout(timeout); + resolve(); + }); + }), + ); try { - state._process.kill("SIGTERM"); + killProcess(proc); } catch (e) { this.ctx.log(`Error stopping sync for ${vaultId}: ${e.message}`); } } } + + if (waitPromises.length > 0) { + await Promise.all(waitPromises); + } + + this.saveStates(); } } diff --git a/server/ws.js b/server/ws.js index 254c978..a94ab17 100644 --- a/server/ws.js +++ b/server/ws.js @@ -6,6 +6,9 @@ const watcher = require("./watcher"); function setupWebSocket(server) { const wss = new WebSocketServer({ server, path: "/ws" }); + // Plugin-registered message handlers: type -> handler(msg, ws) + wss.messageHandlers = new Map(); + wss.on("connection", (ws, req) => { const params = new url.URL(req.url, "http://localhost").searchParams; const vaultId = params.get("vault"); @@ -30,6 +33,18 @@ function setupWebSocket(server) { watcher.addListener(vaultId, listener); + // Dispatch incoming messages to registered handlers + ws.on("message", (data) => { + try { + const msg = JSON.parse(data); + const handler = wss.messageHandlers.get(msg.type); + + if (handler) { + handler(msg, ws); + } + } catch {} + }); + ws.on("close", () => { console.log(`[ws] Client disconnected from vault: ${vaultId}`); watcher.removeListener(vaultId, listener);