refactor headless sync

This commit is contained in:
Nystik 2026-03-30 21:05:47 +02:00
parent ecad257587
commit 300e251734
14 changed files with 619 additions and 223 deletions

View file

@ -69,7 +69,7 @@ Promise.all([
format: "cjs", format: "cjs",
platform: "browser", platform: "browser",
target: ["chrome90"], target: ["chrome90"],
external: ["obsidian"], external: ["obsidian", "fs"], //using fs shim
logLevel: "info", logLevel: "info",
}), }),
]).catch(() => process.exit(1)); ]).catch(() => process.exit(1));

View file

@ -6,7 +6,7 @@ const {
reconcilePluginTabs, reconcilePluginTabs,
hideIgnisFromCommunityPlugins, hideIgnisFromCommunityPlugins,
restoreCommunityPlugins, restoreCommunityPlugins,
clearOwnedNavItems, clearOwnedPluginIds,
} = require("./plugin-tabs"); } = require("./plugin-tabs");
function removeExistingIgnisGroups(tabHeadersEl) { 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) { function injectIgnisSettings(setting, app) {
removeExistingIgnisGroups(setting.tabHeadersEl); removeExistingIgnisGroups(setting.tabHeadersEl);
clearOwnedNavItems(); clearOwnedPluginIds();
allIgnisNavEls.clear();
patchOpenTab(setting);
const ignis = createGroup("Ignis"); const ignis = createGroup("Ignis");
@ -44,6 +77,7 @@ function injectIgnisSettings(setting, app) {
for (const tab of tabs) { for (const tab of tabs) {
tab.navEl = createNavEl(tab, setting); tab.navEl = createNavEl(tab, setting);
ignis.items.appendChild(tab.navEl); ignis.items.appendChild(tab.navEl);
allIgnisNavEls.set(tab.id, tab.navEl);
} }
setting.tabHeadersEl.appendChild(ignis.group); setting.tabHeadersEl.appendChild(ignis.group);
@ -52,7 +86,7 @@ function injectIgnisSettings(setting, app) {
setting.tabHeadersEl.appendChild(corePlugins.group); setting.tabHeadersEl.appendChild(corePlugins.group);
hideIgnisFromCommunityPlugins(setting); hideIgnisFromCommunityPlugins(setting);
setupPluginTabs(setting, corePlugins.items); setupPluginTabs(setting, corePlugins.items, allIgnisNavEls);
} }
function patchSettingsModal(plugin) { function patchSettingsModal(plugin) {
@ -71,10 +105,13 @@ function unpatchSettingsModal(plugin) {
plugin.app.setting.onOpen = plugin._originalOnOpen; plugin.app.setting.onOpen = plugin._originalOnOpen;
} }
delete plugin.app.setting._ignisOpenTabPatched;
restoreCommunityPlugins(plugin.app.setting); restoreCommunityPlugins(plugin.app.setting);
clearOwnedNavItems(); clearOwnedPluginIds();
} }
window.__ignisReconcilePluginTabs = reconcilePluginTabs; window.__ignisReconcilePluginTabs = (setting) =>
reconcilePluginTabs(setting, allIgnisNavEls);
module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs }; module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };

View file

@ -2,23 +2,20 @@ const { setIcon } = require("obsidian");
const { findGroupByTitle } = require("./settings-ui"); const { findGroupByTitle } = require("./settings-ui");
const { isIgnisPlugin } = require("../plugin-registry"); const { isIgnisPlugin } = require("../plugin-registry");
// Tracks our own nav items in the "Ignis Core Plugins" group, keyed by plugin ID. // Tracks which plugin IDs have nav items we created.
const ownedNavItems = new Map(); const ownedPluginIds = new Set();
function addPluginNavItem(pluginId, setting, corePluginsItems) { function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) {
// Find the tab object Obsidian created for this plugin.
const tab = setting.pluginTabs.find((t) => t.id === pluginId); const tab = setting.pluginTabs.find((t) => t.id === pluginId);
if (!tab) { if (!tab) {
return; return;
} }
// Don't add if we already have one for this ID. if (ownedPluginIds.has(pluginId)) {
if (ownedNavItems.has(pluginId)) {
return; return;
} }
// Create our own nav item that delegates to Obsidian's tab.
const nav = document.createElement("div"); const nav = document.createElement("div");
nav.className = "vertical-tab-nav-item tappable"; nav.className = "vertical-tab-nav-item tappable";
@ -43,15 +40,17 @@ function addPluginNavItem(pluginId, setting, corePluginsItems) {
}); });
corePluginsItems.appendChild(nav); corePluginsItems.appendChild(nav);
ownedNavItems.set(pluginId, nav); ownedPluginIds.add(pluginId);
ignisNavEls.set(pluginId, nav);
} }
function removePluginNavItem(pluginId) { function removePluginNavItem(pluginId, ignisNavEls) {
const nav = ownedNavItems.get(pluginId); const nav = ignisNavEls.get(pluginId);
if (nav) { if (nav && ownedPluginIds.has(pluginId)) {
nav.remove(); nav.remove();
ownedNavItems.delete(pluginId); ownedPluginIds.delete(pluginId);
ignisNavEls.delete(pluginId);
} }
} }
@ -104,14 +103,12 @@ function hideIgnisNavFromCommunityGroup(setting) {
return; return;
} }
// Hide any ignis plugin nav items that Obsidian placed here.
for (const tab of setting.pluginTabs) { for (const tab of setting.pluginTabs) {
if (isIgnisPlugin(tab.id) && tab.navEl?.parentElement === items) { if (isIgnisPlugin(tab.id) && tab.navEl?.parentElement === items) {
tab.navEl.style.display = "none"; tab.navEl.style.display = "none";
} }
} }
// Hide the entire group if no visible items remain.
const hasVisible = Array.from(items.children).some( const hasVisible = Array.from(items.children).some(
(el) => el.style.display !== "none", (el) => el.style.display !== "none",
); );
@ -119,45 +116,40 @@ function hideIgnisNavFromCommunityGroup(setting) {
communityGroup.style.display = hasVisible ? "" : "none"; communityGroup.style.display = hasVisible ? "" : "none";
} }
function hideCorePluginsGroupIfEmpty() { function hideCorePluginsGroupIfEmpty(ignisNavEls) {
for (const [, nav] of ownedNavItems) { let hasConnected = false;
if (nav.isConnected) {
const group = nav.closest(".vertical-tab-header-group");
if (group) { for (const id of ownedPluginIds) {
group.style.display = ""; 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"); const groups = document.querySelectorAll(".vertical-tab-header-group");
for (const g of groups) { for (const g of groups) {
const title = g.querySelector(".vertical-tab-header-group-title"); const title = g.querySelector(".vertical-tab-header-group-title");
if (title?.textContent === "Ignis Core Plugins") { if (title?.textContent === "Ignis Core Plugins") {
g.style.display = ownedNavItems.size > 0 ? "" : "none"; g.style.display = hasConnected ? "" : "none";
break; break;
} }
} }
} }
function setupPluginTabs(setting, corePluginsItems) { function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
// Create our own nav items for ignis plugin tabs.
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); addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls);
} }
} }
hideIgnisNavFromCommunityGroup(setting); 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( const communityGroup = findGroupByTitle(
setting.tabHeadersEl, setting.tabHeadersEl,
"Community plugins", "Community plugins",
@ -167,17 +159,19 @@ function setupPluginTabs(setting, corePluginsItems) {
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); addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls);
} }
} }
// Re-evaluate visibility since non-ignis items may have appeared.
hideIgnisNavFromCommunityGroup(setting); hideIgnisNavFromCommunityGroup(setting);
hideCorePluginsGroupIfEmpty(); hideCorePluginsGroupIfEmpty(ignisNavEls);
}); });
observer.observe(communityGroup, { childList: true, subtree: true }); observer.observe(communityGroup, { childList: true, subtree: true });
const modalEl = setting.tabHeadersEl.closest(".modal");
if (modalEl && modalEl.parentElement) {
const cleanupObserver = new MutationObserver(() => { const cleanupObserver = new MutationObserver(() => {
if (!setting.tabHeadersEl.isConnected) { if (!setting.tabHeadersEl.isConnected) {
observer.disconnect(); observer.disconnect();
@ -185,14 +179,14 @@ function setupPluginTabs(setting, corePluginsItems) {
} }
}); });
cleanupObserver.observe(document.body, { cleanupObserver.observe(modalEl.parentElement, {
childList: true, childList: true,
subtree: true,
}); });
} }
} }
}
function reconcilePluginTabs(setting) { function reconcilePluginTabs(setting, ignisNavEls) {
const corePluginsGroup = findGroupByTitle( const corePluginsGroup = findGroupByTitle(
setting.tabHeadersEl, setting.tabHeadersEl,
"Ignis Core Plugins", "Ignis Core Plugins",
@ -210,31 +204,28 @@ function reconcilePluginTabs(setting) {
return; return;
} }
// Get current set of ignis plugin IDs from pluginTabs.
const activeIds = new Set( const activeIds = new Set(
setting.pluginTabs setting.pluginTabs
.filter((t) => isIgnisPlugin(t.id) && t.id !== "ignis-bridge") .filter((t) => isIgnisPlugin(t.id) && t.id !== "ignis-bridge")
.map((t) => t.id), .map((t) => t.id),
); );
// Remove nav items for plugins that are no longer active. for (const id of ownedPluginIds) {
for (const [id] of ownedNavItems) {
if (!activeIds.has(id)) { if (!activeIds.has(id)) {
removePluginNavItem(id); removePluginNavItem(id, ignisNavEls);
} }
} }
// Add nav items for newly active plugins.
for (const id of activeIds) { for (const id of activeIds) {
addPluginNavItem(id, setting, corePluginsItems); addPluginNavItem(id, setting, corePluginsItems, ignisNavEls);
} }
hideIgnisNavFromCommunityGroup(setting); hideIgnisNavFromCommunityGroup(setting);
hideCorePluginsGroupIfEmpty(); hideCorePluginsGroupIfEmpty(ignisNavEls);
} }
function clearOwnedNavItems() { function clearOwnedPluginIds() {
ownedNavItems.clear(); ownedPluginIds.clear();
} }
module.exports = { module.exports = {
@ -242,5 +233,5 @@ module.exports = {
reconcilePluginTabs, reconcilePluginTabs,
hideIgnisFromCommunityPlugins, hideIgnisFromCommunityPlugins,
restoreCommunityPlugins, restoreCommunityPlugins,
clearOwnedNavItems, clearOwnedPluginIds,
}; };

View file

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

View file

@ -2,6 +2,7 @@ const path = require("path");
const obCli = require("./ob-cli"); const obCli = require("./ob-cli");
const auth = require("./auth"); const auth = require("./auth");
const { SyncManager } = require("./sync-manager"); const { SyncManager } = require("./sync-manager");
const { SyncBroadcaster } = require("./broadcaster");
module.exports = { module.exports = {
id: "headless-sync", id: "headless-sync",
@ -15,6 +16,7 @@ module.exports = {
_ctx: null, _ctx: null,
_obStatus: null, _obStatus: null,
_syncManager: null, _syncManager: null,
_broadcaster: null,
async register(ctx) { async register(ctx) {
this._ctx = ctx; this._ctx = ctx;
@ -33,7 +35,8 @@ module.exports = {
ctx.log("Auth token loaded"); 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 // Load saved sync states for enabled vaults
const enabledVaults = ctx.getEnabledVaults(); const enabledVaults = ctx.getEnabledVaults();
@ -56,9 +59,22 @@ module.exports = {
const { mountRoutes } = require("./routes"); const { mountRoutes } = require("./routes");
mountRoutes(ctx.router, this); mountRoutes(ctx.router, this);
// Register WebSocket message handler for log subscriptions
if (ctx.wss && ctx.wss.messageHandlers) {
ctx.wss.messageHandlers.set("subscribe-logs", (msg) => {
if (msg.vaultId && this._broadcaster) {
this._broadcaster.subscribeToLogs(msg.vaultId);
}
});
}
}, },
async shutdown() { async shutdown() {
if (this._ctx?.wss?.messageHandlers) {
this._ctx.wss.messageHandlers.delete("subscribe-logs");
}
if (this._syncManager) { if (this._syncManager) {
await this._syncManager.shutdown(); await this._syncManager.shutdown();
this._syncManager = null; this._syncManager = null;

View file

@ -1,7 +1,7 @@
{ {
"id": "ignis-headless-sync", "id": "ignis-headless-sync",
"name": "Ignis Headless Sync", "name": "Ignis Headless Sync",
"version": "0.1.0", "version": "0.2.0",
"minAppVersion": "1.12.4", "minAppVersion": "1.12.4",
"description": "Client-side companion for server-side Obsidian Sync", "description": "Client-side companion for server-side Obsidian Sync",
"author": "Ignis", "author": "Ignis",

View file

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

View file

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

View file

@ -2,6 +2,7 @@ const { Plugin } = require("obsidian");
const { HeadlessSyncSettingTab } = require("./settings-tab"); const { HeadlessSyncSettingTab } = require("./settings-tab");
const { WsListener } = require("./ws-listener"); const { WsListener } = require("./ws-listener");
const { initSyncStatusBar } = require("./sync-status-bar"); const { initSyncStatusBar } = require("./sync-status-bar");
const { startCoreSyncWatcher } = require("./core-sync-guard");
const api = require("./api"); const api = require("./api");
class IgnisHeadlessSyncPlugin extends Plugin { class IgnisHeadlessSyncPlugin extends Plugin {
@ -13,6 +14,8 @@ class IgnisHeadlessSyncPlugin extends Plugin {
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this)); this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
this._coreSyncWatcher = startCoreSyncWatcher(this, api, this.wsListener);
this.addCommand({ this.addCommand({
id: "start-sync", id: "start-sync",
name: "Start server-side sync", name: "Start server-side sync",
@ -50,6 +53,11 @@ class IgnisHeadlessSyncPlugin extends Plugin {
} }
onunload() { onunload() {
if (this._coreSyncWatcher) {
this._coreSyncWatcher.cleanup();
this._coreSyncWatcher = null;
}
if (this._syncStatusBarCleanup) { if (this._syncStatusBarCleanup) {
this._syncStatusBarCleanup(); this._syncStatusBarCleanup();
this._syncStatusBarCleanup = null; this._syncStatusBarCleanup = null;

View file

@ -1,17 +1,53 @@
const { PluginSettingTab, Setting, Notice } = require("obsidian"); const { PluginSettingTab, Setting, Notice } = require("obsidian");
const api = require("./api"); const api = require("./api");
const auth = require("./auth"); const auth = require("./auth");
const { isCoreSyncEnabled } = require("./core-sync-guard");
const { renderLogViewer } = require("./log-viewer");
class HeadlessSyncSettingTab extends PluginSettingTab { class HeadlessSyncSettingTab extends PluginSettingTab {
constructor(app, plugin) { constructor(app, plugin) {
super(app, plugin); super(app, plugin);
this._cancelWait = null; this._cancelWait = null;
this._logCleanup = null;
// Persistent container refs
this._authEl = null;
this._syncEl = null;
this._logsEl = null;
this._logsRendered = false;
} }
async display() { async display() {
// Clean up previous log listener before rebuilding
if (this._logCleanup) {
this._logCleanup();
this._logCleanup = null;
}
const { containerEl } = this; const { containerEl } = this;
containerEl.empty(); 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; let serverStatus;
try { try {
@ -32,16 +68,21 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
return; return;
} }
this.renderAuthSection(containerEl, serverStatus); this._authEl = containerEl.createDiv();
await this.renderSyncSection(containerEl, serverStatus.authenticated); 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(); const localToken = auth.getObsidianSyncToken();
if (serverStatus.authenticated) { if (serverStatus.authenticated) {
// State C: connected to server new Setting(this._authEl)
new Setting(containerEl)
.setName("Obsidian Sync account") .setName("Obsidian Sync account")
.setDesc( .setDesc(
`Signed in as ${serverStatus.name || "unknown"} (${serverStatus.email || "unknown"})`, `Signed in as ${serverStatus.name || "unknown"} (${serverStatus.email || "unknown"})`,
@ -53,15 +94,16 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
try { try {
await api.logout(); await api.logout();
new Notice("Disconnected from Headless Sync"); new Notice("Disconnected from Headless Sync");
this.display(); const status = await api.getStatus();
this.renderAuthSection(status);
await this.renderSyncSection(status.authenticated);
} catch (e) { } catch (e) {
new Notice(`Failed to disconnect: ${e.message}`); new Notice(`Failed to disconnect: ${e.message}`);
} }
}); });
}); });
} else if (localToken) { } else if (localToken) {
// State B: signed into Obsidian, not connected to server new Setting(this._authEl)
new Setting(containerEl)
.setName("Obsidian Sync account detected") .setName("Obsidian Sync account detected")
.setDesc(`${localToken.name} (${localToken.email})`) .setDesc(`${localToken.name} (${localToken.email})`)
.addButton((btn) => { .addButton((btn) => {
@ -72,15 +114,16 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
try { try {
await auth.sendTokenToServer(localToken); await auth.sendTokenToServer(localToken);
new Notice("Connected to Headless Sync"); new Notice("Connected to Headless Sync");
this.display(); const status = await api.getStatus();
this.renderAuthSection(status);
await this.renderSyncSection(status.authenticated);
} catch (e) { } catch (e) {
new Notice(`Failed to connect: ${e.message}`); new Notice(`Failed to connect: ${e.message}`);
} }
}); });
}); });
} else { } else {
// State A: not signed into Obsidian new Setting(this._authEl)
new Setting(containerEl)
.setName("Obsidian Sync account") .setName("Obsidian Sync account")
.setDesc("Sign in to your Obsidian account to enable sync.") .setDesc("Sign in to your Obsidian account to enable sync.")
.addButton((btn) => { .addButton((btn) => {
@ -88,16 +131,20 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
const triggered = auth.triggerLogin(this.app); const triggered = auth.triggerLogin(this.app);
if (!triggered) { if (!triggered) {
new Notice("Could not open login dialog. Try logging in from Settings > General."); new Notice(
"Could not open login dialog. Try logging in from Settings > General.",
);
return; return;
} }
this._cancelWait = auth.waitForLogin((token) => { this._cancelWait = auth.waitForLogin(async (token) => {
this._cancelWait = null; this._cancelWait = null;
if (token) { if (token) {
new Notice(`Detected login: ${token.name}`); new Notice(`Detected login: ${token.name}`);
this.display(); const status = await api.getStatus();
this.renderAuthSection(status);
await this.renderSyncSection(status.authenticated);
} }
}); });
}); });
@ -105,11 +152,13 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
} }
} }
async renderSyncSection(containerEl, authenticated) { async renderSyncSection(authenticated) {
containerEl.createEl("h3", { text: "Vault sync" }); this._syncEl.empty();
this._syncEl.createEl("h3", { text: "Vault sync" });
if (!authenticated) { if (!authenticated) {
new Setting(containerEl) new Setting(this._syncEl)
.setName("Sync not configured") .setName("Sync not configured")
.setDesc("Sign in to your Obsidian Sync account to set up sync.") .setDesc("Sign in to your Obsidian Sync account to set up sync.")
.addButton((btn) => { .addButton((btn) => {
@ -127,7 +176,7 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
try { try {
vaultsData = await api.getVaults(); vaultsData = await api.getVaults();
} catch (e) { } catch (e) {
containerEl.createEl("p", { this._syncEl.createEl("p", {
text: `Failed to load sync state: ${e.message}`, text: `Failed to load sync state: ${e.message}`,
cls: "mod-warning", cls: "mod-warning",
}); });
@ -137,7 +186,7 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
const vaultState = vaultsData.vaults.find((v) => v.vaultId === vaultId); const vaultState = vaultsData.vaults.find((v) => v.vaultId === vaultId);
if (!vaultState) { if (!vaultState) {
new Setting(containerEl) new Setting(this._syncEl)
.setName("Sync not configured") .setName("Sync not configured")
.setDesc("This vault has not been linked to a remote vault yet.") .setDesc("This vault has not been linked to a remote vault yet.")
.addButton((btn) => { .addButton((btn) => {
@ -157,10 +206,10 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
target: document.body, target: document.body,
props: { props: {
vaultId, vaultId,
onSuccess: () => { onSuccess: async () => {
cleanup(); cleanup();
modal.$destroy(); modal.$destroy();
this.display(); await this.renderSyncSection(true);
}, },
}, },
}); });
@ -176,9 +225,11 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
} }
// Show current sync config // Show current sync config
new Setting(containerEl) new Setting(this._syncEl)
.setName("Remote vault") .setName("Remote vault")
.setDesc(vaultState.remoteVaultName || vaultState.remoteVault || "unknown") .setDesc(
vaultState.remoteVaultName || vaultState.remoteVault || "unknown",
)
.addButton((btn) => { .addButton((btn) => {
btn.setButtonText("Unlink"); btn.setButtonText("Unlink");
btn.buttonEl.addClass("mod-destructive"); btn.buttonEl.addClass("mod-destructive");
@ -186,18 +237,44 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
try { try {
await api.unlinkVault(vaultId); await api.unlinkVault(vaultId);
new Notice("Vault unlinked"); new Notice("Vault unlinked");
this.display(); await this.renderSyncSection(true);
} catch (e) { } catch (e) {
new Notice(`Failed to unlink: ${e.message}`); new Notice(`Failed to unlink: ${e.message}`);
} }
}); });
}); });
new Setting(containerEl) new Setting(this._syncEl)
.setName("Sync mode") .setName("Sync mode")
.setDesc(vaultState.config?.mode || "bidirectional"); .setDesc(vaultState.config?.mode || "bidirectional");
// Sync controls // 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 = const statusText =
vaultState.status === "running" vaultState.status === "running"
? "Sync is running" ? "Sync is running"
@ -216,91 +293,34 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
try { try {
await api.stopSync(vaultId); await api.stopSync(vaultId);
new Notice("Sync stopped"); new Notice("Sync stopped");
this.display(); this.renderSyncControls(containerEl, vaultId);
} catch (e) { } catch (e) {
new Notice(`Failed to stop: ${e.message}`); new Notice(`Failed to stop: ${e.message}`);
} }
}); });
} else { } else {
btn.setButtonText("Start sync").setCta().onClick(async () => { btn
.setButtonText("Start sync")
.setCta()
.onClick(async () => {
try { try {
await api.startSync(vaultId); await api.startSync(vaultId);
new Notice("Sync started"); new Notice("Sync started");
this.display(); this.renderSyncControls(containerEl, vaultId);
} catch (e) { } catch (e) {
new Notice(`Failed to start: ${e.message}`); new Notice(`Failed to start: ${e.message}`);
} }
}); });
} }
}); });
// Log viewer (collapsible)
await this.renderLogs(containerEl, vaultId);
} }
async renderLogs(containerEl, vaultId) { async renderLogs(containerEl, vaultId) {
const details = containerEl.createEl("details", { this._logCleanup = await renderLogViewer(
cls: "ignis-log-details", containerEl,
}); vaultId,
this.plugin.wsListener,
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);
} }
hide() { hide() {

View file

@ -62,6 +62,8 @@ function initSyncStatusBar(plugin, wsListener) {
popoverOpen = true; popoverOpen = true;
wsListener.subscribeLogs(vaultId);
outsideClickHandler = (e) => { outsideClickHandler = (e) => {
if (!item.contains(e.target)) { if (!item.contains(e.target)) {
hidePopover(); hidePopover();
@ -84,6 +86,7 @@ function initSyncStatusBar(plugin, wsListener) {
outsideClickHandler = null; outsideClickHandler = null;
} }
wsListener.unsubscribeLogs();
popoverOpen = false; popoverOpen = false;
} }
@ -119,6 +122,13 @@ function initSyncStatusBar(plugin, wsListener) {
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+(.+)$/);
if (match) {
return { prefix: "Syncing", path: match[1].trim() };
}
// Deleting path // Deleting path
match = line.match(/^Deleting\s+(.+)$/); match = line.match(/^Deleting\s+(.+)$/);
@ -238,8 +248,7 @@ function initSyncStatusBar(plugin, wsListener) {
let wasDisconnected = false; let wasDisconnected = false;
const wsCheckInterval = setInterval(() => { const wsCheckInterval = setInterval(() => {
const ws = window.__ignisWs; const disconnected = !wsListener.isConnected();
const disconnected = !ws || ws.readyState !== WebSocket.OPEN;
if (disconnected && currentStatus === "running") { if (disconnected && currentStatus === "running") {
updateState("error", "Server connection lost"); updateState("error", "Server connection lost");

View file

@ -1,12 +1,16 @@
const CHANNEL = "plugin:headless-sync"; const CHANNEL = "plugin:headless-sync";
const POLL_INTERVAL = 3000; const POLL_INTERVAL = 3000;
const LOG_KEEPALIVE_INTERVAL = 7000;
class WsListener { class WsListener {
constructor() { constructor() {
this._callbacks = new Map(); this._callbacks = new Map();
this._handler = null; this._handler = null;
this._rawHandler = null;
this._pollTimer = null; this._pollTimer = null;
this._currentWs = null; this._currentWs = null;
this._logSubInterval = null;
this._logSubVaultId = null;
} }
start() { start() {
@ -23,9 +27,15 @@ class WsListener {
this._pollTimer = null; this._pollTimer = null;
} }
this.unsubscribeLogs();
this._detachFromWs(); this._detachFromWs();
} }
isConnected() {
const ws = window.__ignisWs;
return ws && ws.readyState === WebSocket.OPEN;
}
on(type, callback) { on(type, callback) {
if (!this._callbacks.has(type)) { if (!this._callbacks.has(type)) {
this._callbacks.set(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() { _attachToWs() {
const ws = window.__ignisWs; const ws = window.__ignisWs;
@ -62,6 +118,11 @@ class WsListener {
try { try {
const msg = JSON.parse(event.data); 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) { if (msg.channel !== CHANNEL) {
return; return;
} }

View file

@ -1,12 +1,26 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { spawn } = require("child_process");
const { spawnOb, runCommand } = require("./ob-cli"); const { spawnOb, runCommand } = require("./ob-cli");
const MAX_LOG_ENTRIES = 200; 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 { class SyncManager {
constructor(ctx) { constructor(ctx, broadcaster) {
this.ctx = ctx; this.ctx = ctx;
this.broadcaster = broadcaster;
this.states = new Map(); this.states = new Map();
this.stateFile = path.join(ctx.dataDir, "sync-states.json"); this.stateFile = path.join(ctx.dataDir, "sync-states.json");
} }
@ -66,8 +80,6 @@ class SyncManager {
} }
async setupSync(vaultId, vaultPath, remoteVault, options = {}) { async setupSync(vaultId, vaultPath, remoteVault, options = {}) {
const obCli = require("./ob-cli");
const args = ["sync-setup", "--vault", remoteVault, "--path", "."]; const args = ["sync-setup", "--vault", remoteVault, "--path", "."];
if (options.vaultPassword) { if (options.vaultPassword) {
@ -78,7 +90,7 @@ class SyncManager {
args.push("--device-name", options.deviceName); args.push("--device-name", options.deviceName);
} }
await obCli.runCommand(args, { cwd: vaultPath }); await runCommand(args, { cwd: vaultPath });
const state = { const state = {
vaultId, vaultId,
@ -112,6 +124,12 @@ class SyncManager {
throw new Error(`No sync configuration for vault: ${vaultId}`); 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") { if (state.status === "running") {
this.ctx.log(`Sync already running for ${vaultId}`); this.ctx.log(`Sync already running for ${vaultId}`);
return this.getState(vaultId); return this.getState(vaultId);
@ -142,7 +160,7 @@ class SyncManager {
if (line.trim()) { if (line.trim()) {
this.addLog(state, line.trim()); this.addLog(state, line.trim());
state.lastActivity = new Date().toISOString(); 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) => { 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.status = code === 0 ? "stopped" : "error";
state.pid = null; state.pid = null;
state._process = null; state._process = null;
@ -170,7 +195,7 @@ class SyncManager {
} }
this.ctx.log(`Sync stopped for ${vaultId} (code: ${code})`); this.ctx.log(`Sync stopped for ${vaultId} (code: ${code})`);
this.broadcastStatus(vaultId); this.broadcaster.broadcastStatus(this.getState(vaultId));
this.saveStates(); this.saveStates();
}); });
@ -182,11 +207,11 @@ class SyncManager {
this.addLog(state, `Error: ${err.message}`); this.addLog(state, `Error: ${err.message}`);
this.ctx.log(`Sync error for ${vaultId}: ${err.message}`); this.ctx.log(`Sync error for ${vaultId}: ${err.message}`);
this.broadcastStatus(vaultId); this.broadcaster.broadcastStatus(this.getState(vaultId));
this.saveStates(); this.saveStates();
}); });
this.broadcastStatus(vaultId); this.broadcaster.broadcastStatus(this.getState(vaultId));
this.ctx.log(`Started sync for ${vaultId} (pid: ${proc.pid})`); this.ctx.log(`Started sync for ${vaultId} (pid: ${proc.pid})`);
this.saveStates(); this.saveStates();
@ -200,7 +225,8 @@ class SyncManager {
throw new Error(`No active sync for vault: ${vaultId}`); throw new Error(`No active sync for vault: ${vaultId}`);
} }
state._process.kill("SIGTERM"); state._userStopped = true;
killProcess(state._process);
state.status = "stopped"; state.status = "stopped";
state.pid = null; state.pid = null;
state.autoStart = false; state.autoStart = false;
@ -208,7 +234,7 @@ class SyncManager {
this.addLog(state, "Sync stopped by user"); this.addLog(state, "Sync stopped by user");
this.ctx.log(`Stopped sync for ${vaultId}`); this.ctx.log(`Stopped sync for ${vaultId}`);
this.broadcastStatus(vaultId); this.broadcaster.broadcastStatus(this.getState(vaultId));
this.saveStates(); this.saveStates();
return this.getState(vaultId); return this.getState(vaultId);
@ -222,7 +248,8 @@ class SyncManager {
} }
if (state._process) { 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 // Tell ob to disconnect from the remote vault and clear its stored config
@ -289,51 +316,31 @@ class SyncManager {
} }
} }
broadcastLog(vaultId, line) { isCoreSyncEnabled(vaultPath) {
if (!this.ctx.wss || !this.ctx.wss.clients) { try {
return; const configPath = path.join(vaultPath, ".obsidian", "core-plugins.json");
} const data = fs.readFileSync(configPath, "utf-8");
const config = JSON.parse(data);
const message = JSON.stringify({ return config.sync === true;
channel: "plugin:headless-sync", } catch {
type: "sync-log", return false;
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);
}
}
} }
} }
autoStartAll() { autoStartAll() {
let started = 0; let started = 0;
let skipped = 0;
for (const [vaultId, state] of this.states) { for (const [vaultId, state] of this.states) {
if (state.autoStart && state.status === "stopped") { 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 { try {
this.startSync(vaultId); this.startSync(vaultId);
started++; started++;
@ -346,22 +353,50 @@ class SyncManager {
if (started > 0) { if (started > 0) {
this.ctx.log(`Auto-started sync for ${started} vault(s)`); 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() { async shutdown() {
this.ctx.log("Shutting down sync manager..."); this.ctx.log("Shutting down sync manager...");
const waitPromises = [];
for (const [vaultId, state] of this.states) { for (const [vaultId, state] of this.states) {
if (state._process) { if (state._process) {
this.ctx.log(`Stopping sync for ${vaultId}...`); 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 { try {
state._process.kill("SIGTERM"); killProcess(proc);
} catch (e) { } catch (e) {
this.ctx.log(`Error stopping sync for ${vaultId}: ${e.message}`); this.ctx.log(`Error stopping sync for ${vaultId}: ${e.message}`);
} }
} }
} }
if (waitPromises.length > 0) {
await Promise.all(waitPromises);
}
this.saveStates();
} }
} }

View file

@ -6,6 +6,9 @@ const watcher = require("./watcher");
function setupWebSocket(server) { function setupWebSocket(server) {
const wss = new WebSocketServer({ server, path: "/ws" }); const wss = new WebSocketServer({ server, path: "/ws" });
// Plugin-registered message handlers: type -> handler(msg, ws)
wss.messageHandlers = new Map();
wss.on("connection", (ws, req) => { wss.on("connection", (ws, req) => {
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");
@ -30,6 +33,18 @@ function setupWebSocket(server) {
watcher.addListener(vaultId, listener); 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", () => { 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);