move server into apps/ignis-server

This commit is contained in:
Nystik
2026-05-21 17:26:08 +02:00
parent a6807fe850
commit 8672fa11a3
65 changed files with 19 additions and 10 deletions

View File

@@ -0,0 +1,133 @@
const fs = require("fs");
const path = require("path");
const { getObHome } = require("./ob-cli");
function getObAuthFile(dataDir) {
return path.join(
getObHome(dataDir),
".config",
"obsidian-headless",
"auth_token",
);
}
function getInternalTokenFile(dataDir) {
return path.join(dataDir, "auth-token.json");
}
function loadToken(dataDir) {
const internalFile = getInternalTokenFile(dataDir);
try {
if (fs.existsSync(internalFile)) {
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
if (data && data.token) {
syncToObCli(dataDir, data.token);
return data;
}
}
} catch {}
// Fall back to ob CLI's own auth file
const obAuthFile = getObAuthFile(dataDir);
try {
if (fs.existsSync(obAuthFile)) {
const token = fs.readFileSync(obAuthFile, "utf-8").trim();
if (token) {
const data = { token };
saveInternal(dataDir, data);
return data;
}
}
} catch {}
return null;
}
function saveToken(dataDir, tokenData) {
saveInternal(dataDir, tokenData);
syncToObCli(dataDir, tokenData.token);
}
function clearToken(dataDir) {
const internalFile = getInternalTokenFile(dataDir);
try {
if (fs.existsSync(internalFile)) {
fs.unlinkSync(internalFile);
}
} catch {}
const obAuthFile = getObAuthFile(dataDir);
try {
if (fs.existsSync(obAuthFile)) {
fs.unlinkSync(obAuthFile);
}
} catch {}
}
function isAuthenticated(dataDir) {
const internalFile = getInternalTokenFile(dataDir);
try {
if (fs.existsSync(internalFile)) {
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
return !!(data && data.token);
}
} catch {}
return false;
}
function saveInternal(dataDir, tokenData) {
const internalFile = getInternalTokenFile(dataDir);
const dir = path.dirname(internalFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8");
}
function syncToObCli(dataDir, token) {
const obAuthFile = getObAuthFile(dataDir);
try {
const dir = path.dirname(obAuthFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(obAuthFile, token, "utf-8");
} catch {}
}
function getTokenInfo(dataDir) {
const internalFile = getInternalTokenFile(dataDir);
try {
if (fs.existsSync(internalFile)) {
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
if (data && data.token) {
return { email: data.email || null, name: data.name || null };
}
}
} catch {}
return null;
}
module.exports = {
loadToken,
saveToken,
clearToken,
isAuthenticated,
getTokenInfo,
};

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

@@ -0,0 +1,124 @@
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",
name: "Headless Sync",
description: "Server-side vault sync via obsidian-headless CLI",
version: "0.3.0",
//TODO: add server plugin manifest
obsidianPlugin: path.join(__dirname, "plugin"),
_ctx: null,
_obStatus: null,
_syncManager: null,
_broadcaster: null,
async register(ctx) {
this._ctx = ctx;
this._obStatus = obCli.checkInstalled();
if (this._obStatus.installed) {
ctx.log(`ob CLI available (${this._obStatus.version})`);
} else {
ctx.log("ob CLI not found. Install obsidian-headless to enable sync.");
}
// Redirect ob's HOME under the plugin's data dir so its config (per-vault sync setups, etc.)
// survives container recreates. Must happen before auth.loadToken since loadToken pushes the token into ob's config location via syncToObCli.
obCli.configure({ dataDir: ctx.dataDir });
const token = auth.loadToken(ctx.dataDir);
if (token) {
ctx.log("Auth token loaded");
}
this._broadcaster = new SyncBroadcaster(ctx.wss);
this._syncManager = new SyncManager(ctx, this._broadcaster);
// Load saved sync states for enabled vaults
const enabledVaults = ctx.getEnabledVaults();
const vaultMap = {};
for (const vaultId of enabledVaults) {
const vaultPath = ctx.config.getVaultPath(vaultId);
if (vaultPath) {
vaultMap[vaultId] = vaultPath;
}
}
this._syncManager.loadStates(vaultMap);
// Auto-start syncs that were running before shutdown
if (this._obStatus.installed && auth.isAuthenticated(ctx.dataDir)) {
this._syncManager.autoStartAll();
}
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;
}
this._ctx = null;
},
async onVaultEnabled(vaultId, vaultPath) {
if (this._ctx) {
this._ctx.log(`Vault enabled: ${vaultId}`);
}
},
async onVaultDisabled(vaultId, vaultPath) {
if (!this._ctx) {
return;
}
this._ctx.log(`Vault disabled: ${vaultId}`);
// Stop sync if running, but keep the config
if (this._syncManager) {
const state = this._syncManager.getState(vaultId);
if (state && state.status === "running") {
this._syncManager.stopSync(vaultId);
}
}
},
getObStatus() {
return this._obStatus;
},
getCtx() {
return this._ctx;
},
getSyncManager() {
return this._syncManager;
},
};

View File

@@ -0,0 +1,91 @@
const { spawn, execSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
const isWindows = process.platform === "win32";
// When set via configure(), HOME for the spawned ob points under the plugin's data dir so
// ob's config dir (~/.config/obsidian-headless/) survives container recreates.
let configuredDataDir = null;
function getObHome(dataDir) {
return path.join(dataDir, "ob-home");
}
function configure(opts) {
configuredDataDir = opts && opts.dataDir ? opts.dataDir : null;
if (configuredDataDir) {
try {
fs.mkdirSync(getObHome(configuredDataDir), { recursive: true });
} catch {}
}
}
function checkInstalled() {
try {
const output = execSync("ob --version", {
stdio: "pipe",
windowsHide: true,
})
.toString()
.trim();
return { installed: true, version: output || "unknown" };
} catch {
return { installed: false, version: null };
}
}
function spawnOb(args, opts = {}) {
const home = configuredDataDir
? getObHome(configuredDataDir)
: os.homedir();
return spawn("ob", args, {
env: { ...process.env, HOME: home },
shell: isWindows,
windowsHide: true,
...opts,
});
}
function runCommand(args, opts = {}) {
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
const proc = spawnOb(args, opts);
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(
new Error(`ob ${args[0]} failed (code ${code}): ${stderr || stdout}`),
);
}
});
proc.on("error", (err) => {
reject(err);
});
});
}
module.exports = {
checkInstalled,
spawnOb,
runCommand,
configure,
getObHome,
};

View File

@@ -0,0 +1,9 @@
{
"id": "ignis-headless-sync",
"name": "Ignis Headless Sync",
"version": "0.3.0",
"minAppVersion": "1.12.4",
"description": "Client-side companion for server-side Obsidian Sync",
"author": "Ignis",
"isDesktopOnly": false
}

View File

@@ -0,0 +1,78 @@
const BASE = "/api/ext/headless-sync";
async function fetchJson(path, opts = {}) {
const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Request failed: ${res.status}`);
}
return res.json();
}
function post(path, body) {
return fetchJson(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function getStatus() {
return fetchJson("/status");
}
function login(token, email, name) {
return post("/login", { token, email, name });
}
function logout() {
return post("/logout", {});
}
function getRemoteVaults() {
return fetchJson("/remote-vaults");
}
function setupSync(vaultId, remoteVault, opts = {}) {
return post("/setup", { vaultId, remoteVault, ...opts });
}
function createRemoteVault(name, encryption, password, region) {
return post("/create-remote-vault", { name, encryption, password, region });
}
function startSync(vaultId) {
return post("/start", { vaultId });
}
function stopSync(vaultId) {
return post("/stop", { vaultId });
}
function unlinkVault(vaultId) {
return post("/unlink", { vaultId });
}
function getVaults() {
return fetchJson("/vaults");
}
function getLogs(vaultId, limit = 100) {
return fetchJson(`/logs?vaultId=${encodeURIComponent(vaultId)}&limit=${limit}`);
}
module.exports = {
getStatus,
login,
logout,
getRemoteVaults,
setupSync,
createRemoteVault,
startSync,
stopSync,
unlinkVault,
getVaults,
getLogs,
};

View File

@@ -0,0 +1,69 @@
const api = require("./api");
function getObsidianSyncToken() {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
try {
const val = JSON.parse(localStorage.getItem(key));
if (val?.token && val?.email && val?.name) {
return val;
}
} catch {}
}
return null;
}
function triggerLogin(app) {
const aboutTab = app.setting.settingTabs.find((t) => t.id === "about");
if (!aboutTab || !aboutTab.accountSetting) {
return false;
}
const loginBtn = aboutTab.accountSetting.controlEl.querySelector("button");
if (!loginBtn) {
return false;
}
loginBtn.click();
return true;
}
async function sendTokenToServer(tokenData) {
return api.login(tokenData.token, tokenData.email, tokenData.name);
}
function waitForLogin(callback, timeoutMs = 60000) {
const interval = 2000;
let elapsed = 0;
const timer = setInterval(() => {
elapsed += interval;
const token = getObsidianSyncToken();
if (token) {
clearInterval(timer);
callback(token);
return;
}
if (elapsed >= timeoutMs) {
clearInterval(timer);
callback(null);
}
}, interval);
return () => clearInterval(timer);
}
module.exports = {
getObsidianSyncToken,
triggerLogin,
sendTokenToServer,
waitForLogin,
};

View File

@@ -0,0 +1,95 @@
const { Notice } = require("obsidian");
const fs = require("fs");
const CORE_PLUGINS_PATH = ".obsidian/core-plugins.json";
// Reads core-plugins.json via the fs shim. When headless sync is active,
// the shim patches sync: false, so this returns false. When the flag is
// cleared (user action), this returns the real value.
function isCoreSyncEnabled() {
try {
const data = fs.readFileSync(CORE_PLUGINS_PATH, "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 startCoreSyncGuard(plugin, api, wsListener) {
const app = plugin.app;
const vaultId = app.vault.getName();
// Monkey-patch syncPlugin.enable() to clear the shim flag before
// Obsidian writes core-plugins.json. This ensures the read transform
// doesn't block a user-initiated core sync enable.
const syncPlugin = app.internalPlugins.getPluginById("sync");
let origEnable = null;
if (syncPlugin) {
origEnable = syncPlugin.enable.bind(syncPlugin);
syncPlugin.enable = function (...args) {
window.__ignisHeadlessSyncActive = false;
api.stopSync(vaultId).catch(() => {});
return origEnable(...args);
};
}
// Watch for core-plugins.json changes via WebSocket.
let wasEnabled = isCoreSyncEnabled();
const rawHandler = (msg) => {
if (msg.type === "modified" && msg.path === CORE_PLUGINS_PATH) {
handleCoreSyncChange();
}
};
wsListener.onRaw(rawHandler);
function handleCoreSyncChange() {
const enabled = isCoreSyncEnabled();
if (enabled && !wasEnabled) {
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();
if (syncPlugin && origEnable) {
syncPlugin.enable = origEnable;
}
},
};
}
module.exports = {
isCoreSyncEnabled,
startCoreSyncGuard,
};

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

@@ -0,0 +1,86 @@
const { Plugin } = require("obsidian");
const { HeadlessSyncSettingTab } = require("./settings-tab");
const { WsListener } = require("./ws-listener");
const { initSyncStatusBar } = require("./sync-status-bar");
const { startCoreSyncGuard } = require("./core-sync-guard");
const api = require("./api");
class IgnisHeadlessSyncPlugin extends Plugin {
async onload() {
if (!window.__ignis) {
console.log(
"[ignis-headless-sync] Not running in Ignis - plugin is a no-op.",
);
return;
}
this.wsListener = new WsListener();
this.wsListener.start();
this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener);
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
this._coreSyncGuard = startCoreSyncGuard(this, api, this.wsListener);
this.addCommand({
id: "start-sync",
name: "Start server-side sync",
callback: async () => {
try {
await api.startSync(this.app.vault.getName());
} catch (e) {
console.error("[ignis-headless-sync] Start failed:", e.message);
}
},
});
this.addCommand({
id: "stop-sync",
name: "Stop server-side sync",
callback: async () => {
try {
await api.stopSync(this.app.vault.getName());
} catch (e) {
console.error("[ignis-headless-sync] Stop failed:", e.message);
}
},
});
this.addCommand({
id: "show-status",
name: "Show sync status",
callback: () => {
this.app.setting.open();
this.app.setting.openTabById("ignis-headless-sync");
},
});
console.log("[ignis-headless-sync] Loaded");
}
onunload() {
if (!window.__ignis) {
return;
}
window.__ignisHeadlessSyncActive = false;
if (this._coreSyncGuard) {
this._coreSyncGuard.cleanup();
this._coreSyncGuard = null;
}
if (this._syncStatusBarCleanup) {
this._syncStatusBarCleanup();
this._syncStatusBarCleanup = null;
}
if (this.wsListener) {
this.wsListener.stop();
this.wsListener = null;
}
}
}
module.exports = IgnisHeadlessSyncPlugin;

View File

@@ -0,0 +1,341 @@
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 {
serverStatus = await api.getStatus();
} catch (e) {
containerEl.createEl("p", {
text: "Failed to connect to Headless Sync server plugin.",
cls: "mod-warning",
});
return;
}
if (!serverStatus.installed) {
containerEl.createEl("p", {
text: "obsidian-headless (ob CLI) is not installed on the server. Install it to enable sync.",
cls: "mod-warning",
});
return;
}
this._authEl = containerEl.createDiv();
this._syncEl = containerEl.createDiv();
this._logsEl = containerEl.createDiv();
this.renderAuthSection(serverStatus);
await this.renderSyncSection(serverStatus.authenticated);
}
renderAuthSection(serverStatus) {
this._authEl.empty();
const localToken = auth.getObsidianSyncToken();
if (serverStatus.authenticated) {
new Setting(this._authEl)
.setName("Obsidian Sync account")
.setDesc(
`Signed in as ${serverStatus.name || "unknown"} (${serverStatus.email || "unknown"})`,
)
.addButton((btn) => {
btn.setButtonText("Disconnect");
btn.buttonEl.addClass("mod-destructive");
btn.onClick(async () => {
try {
await api.logout();
new Notice("Disconnected from Headless Sync");
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) {
new Setting(this._authEl)
.setName("Obsidian Sync account detected")
.setDesc(`${localToken.name} (${localToken.email})`)
.addButton((btn) => {
btn
.setButtonText("Use this account for Headless Sync")
.setCta()
.onClick(async () => {
try {
await auth.sendTokenToServer(localToken);
new Notice("Connected to Headless Sync");
const status = await api.getStatus();
this.renderAuthSection(status);
await this.renderSyncSection(status.authenticated);
} catch (e) {
new Notice(`Failed to connect: ${e.message}`);
}
});
});
} else {
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);
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);
}
});
});
});
}
}
async renderSyncSection(authenticated) {
this._syncEl.empty();
this._syncEl.createEl("h3", { text: "Vault sync" });
if (!authenticated) {
new Setting(this._syncEl)
.setName("Sync not configured")
.setDesc("Sign in to your Obsidian Sync account to set up sync.")
.addButton((btn) => {
btn.setButtonText("Set up sync");
btn.buttonEl.disabled = true;
});
return;
}
const vaultId = this.app.vault.getName();
let vaultsData;
try {
vaultsData = await api.getVaults();
} catch (e) {
this._syncEl.createEl("p", {
text: `Failed to load sync state: ${e.message}`,
cls: "mod-warning",
});
return;
}
const vaultState = vaultsData.vaults.find((v) => v.vaultId === vaultId);
if (!vaultState) {
new Setting(this._syncEl)
.setName("Sync not configured")
.setDesc("This vault has not been linked to a remote vault yet.")
.addButton((btn) => {
btn
.setButtonText("Set up sync")
.setCta()
.onClick(() => {
const scope = this.app.setting.scope;
const prevFocusContainer = scope.tabFocusContainerEl;
scope.tabFocusContainerEl = null;
const cleanup = () => {
scope.tabFocusContainerEl = prevFocusContainer;
};
const modal = new window.IgnisUI.SyncSetupModal({
target: document.body,
props: {
vaultId,
onSuccess: async () => {
cleanup();
modal.$destroy();
await this.renderSyncSection(true);
},
},
});
modal.$on("close", () => {
cleanup();
modal.$destroy();
});
});
});
return;
}
// Show current sync config
new Setting(this._syncEl)
.setName("Remote vault")
.setDesc(
vaultState.remoteVaultName || vaultState.remoteVault || "unknown",
)
.addButton((btn) => {
btn.setButtonText("Unlink");
btn.buttonEl.addClass("mod-destructive");
btn.onClick(async () => {
try {
await api.unlinkVault(vaultId);
new Notice("Vault unlinked");
await this.renderSyncSection(true);
} catch (e) {
new Notice(`Failed to unlink: ${e.message}`);
}
});
});
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"
: vaultState.status === "error"
? `Error: ${vaultState.error}`
: "Sync is stopped";
new Setting(containerEl)
.setName("Status")
.setDesc(statusText)
.addButton((btn) => {
if (vaultState.status === "running") {
btn.setButtonText("Stop sync");
btn.buttonEl.addClass("mod-destructive");
btn.onClick(async () => {
try {
await api.stopSync(vaultId);
new Notice("Sync stopped");
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.renderSyncControls(containerEl, vaultId);
} catch (e) {
new Notice(`Failed to start: ${e.message}`);
}
});
}
});
}
async renderLogs(containerEl, vaultId) {
this._logCleanup = await renderLogViewer(
containerEl,
vaultId,
this.plugin.wsListener,
);
}
hide() {
if (this._cancelWait) {
this._cancelWait();
this._cancelWait = null;
}
if (this._logCleanup) {
this._logCleanup();
this._logCleanup = null;
}
super.hide();
}
}
module.exports = { HeadlessSyncSettingTab };

View File

@@ -0,0 +1,283 @@
const { setIcon } = require("obsidian");
const api = require("./api");
const TOOLTIP_MAP = {
running: "Syncing...",
synced: "Synced",
stopped: "Sync stopped",
error: "Sync error",
};
function initSyncStatusBar(plugin, wsListener) {
const vaultId = plugin.app.vault.getName();
const item = plugin.addStatusBarItem();
item.addClass("ignis-sync-statusbar");
item.style.display = "none";
const iconEl = item.createEl("span", { cls: "ignis-sync-icon" });
setIcon(iconEl, "refresh-cw");
let popoverEl = null;
let popoverOpen = false;
let currentStatus = "stopped";
let outsideClickHandler = null;
function updateState(status, error) {
currentStatus = status;
iconEl.className = "ignis-sync-icon";
if (status === "running") {
iconEl.addClass("ignis-sync-syncing");
iconEl.addClass("ignis-sync-spinning");
} else if (status === "error") {
iconEl.addClass("ignis-sync-error");
} else if (status === "stopped") {
iconEl.addClass("ignis-sync-stopped");
} else {
iconEl.addClass("ignis-sync-synced");
}
const tooltip = error || TOOLTIP_MAP[status] || status;
item.setAttribute("aria-label", tooltip);
item.setAttribute("data-tooltip-position", "top");
}
function showPopover(text) {
if (popoverEl) {
const span = popoverEl.querySelector(".ignis-sync-popover-filename");
if (span) {
span.textContent = text;
}
return;
}
popoverEl = item.createEl("div", { cls: "ignis-sync-popover" });
popoverEl.createEl("span", {
text: text,
cls: "ignis-sync-popover-filename",
});
popoverOpen = true;
wsListener.subscribeLogs(vaultId);
outsideClickHandler = (e) => {
if (!item.contains(e.target)) {
hidePopover();
}
};
setTimeout(() => {
document.addEventListener("click", outsideClickHandler, true);
}, 0);
}
function hidePopover() {
if (popoverEl) {
popoverEl.remove();
popoverEl = null;
}
if (outsideClickHandler) {
document.removeEventListener("click", outsideClickHandler, true);
outsideClickHandler = null;
}
wsListener.unsubscribeLogs();
popoverOpen = false;
}
function truncatePath(path, maxLen) {
if (path.length <= maxLen) {
return path;
}
return "\u2026" + path.slice(-(maxLen - 1));
}
function formatPopoverText(prefix, path) {
return `${prefix}: ${truncatePath(path, 46 - prefix.length)}`;
}
function updatePopoverText(text) {
if (!popoverOpen) {
return;
}
const span = popoverEl?.querySelector(".ignis-sync-popover-filename");
if (span) {
span.textContent = text;
}
}
function extractFileActivity(line) {
// Downloading/Downloaded path
let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/);
if (match) {
return { prefix: "Syncing", path: match[1].trim() };
}
// Uploading file / Upload complete path
match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/);
if (match) {
return { prefix: "Syncing", path: match[1].trim() };
}
// Deleting path
match = line.match(/^Deleting\s+(.+)$/);
if (match) {
return { prefix: "Deleting", path: match[1].trim() };
}
// Push: path (updated)
match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/);
if (match) {
return { prefix: "Syncing", path: match[1].trim() };
}
// Push: path (deleted)
match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/);
if (match) {
return { prefix: "Deleting", path: match[1].trim() };
}
return null;
}
function isFullySynced(line) {
return /Fully synced/i.test(line);
}
// Click toggles popover
item.addEventListener("click", () => {
if (popoverOpen) {
hidePopover();
} else {
showPopover(TOOLTIP_MAP[currentStatus] || currentStatus);
}
});
// Listen for status updates
const onStatus = (payload) => {
if (payload.vaultId !== vaultId) {
return;
}
item.style.display = "";
// "running" from server means the process is alive, but we refine
// the visual state based on log activity.
if (payload.status === "running") {
updateState("synced");
} else {
updateState(payload.status, payload.error);
}
};
wsListener.on("sync-status", onStatus);
// Debounce the transition to "synced" state to avoid flickering
// during rapid delete cycles (Fully synced -> Deleting -> Fully synced).
let syncedTimer = null;
function deferSynced() {
if (syncedTimer) {
clearTimeout(syncedTimer);
}
syncedTimer = setTimeout(() => {
syncedTimer = null;
updateState("synced");
updatePopoverText("Synced");
}, 2000);
}
function cancelDeferredSynced() {
if (syncedTimer) {
clearTimeout(syncedTimer);
syncedTimer = null;
}
}
// Listen for log lines
const onLog = (payload) => {
if (payload.vaultId !== vaultId) {
return;
}
if (isFullySynced(payload.line)) {
deferSynced();
return;
}
const activity = extractFileActivity(payload.line);
if (activity) {
cancelDeferredSynced();
updateState("running");
updatePopoverText(formatPopoverText(activity.prefix, activity.path));
}
};
wsListener.on("sync-log", onLog);
// Fetch initial state
api
.getVaults()
.then((data) => {
const vaults = data.vaults || [];
const vault = vaults.find((v) => v.vaultId === vaultId);
if (vault) {
item.style.display = "";
updateState(vault.status, vault.error);
}
})
.catch(() => {});
// Poll WebSocket state to detect server disconnect/reconnect
let wasDisconnected = false;
const wsCheckInterval = setInterval(() => {
const disconnected = !wsListener.isConnected();
if (disconnected && currentStatus === "running") {
updateState("error", "Server connection lost");
wasDisconnected = true;
} else if (!disconnected && wasDisconnected) {
wasDisconnected = false;
api
.getVaults()
.then((data) => {
const vaults = data.vaults || [];
const vault = vaults.find((v) => v.vaultId === vaultId);
if (vault) {
updateState(vault.status, vault.error);
}
})
.catch(() => {});
}
}, 3000);
// Return cleanup function
return () => {
clearInterval(wsCheckInterval);
cancelDeferredSynced();
wsListener.off("sync-status", onStatus);
wsListener.off("sync-log", onLog);
hidePopover();
};
}
module.exports = { initSyncStatusBar };

View File

@@ -0,0 +1,153 @@
const CHANNEL = "plugin:headless-sync";
const POLL_INTERVAL = 3000;
const LOG_KEEPALIVE_INTERVAL = 7000;
class WsListener {
constructor() {
this._callbacks = new Map();
this._handler = null;
this._rawHandler = null;
this._pollTimer = null;
this._currentWs = null;
this._logSubInterval = null;
this._logSubVaultId = null;
}
start() {
this._attachToWs();
this._pollTimer = setInterval(() => {
this._attachToWs();
}, POLL_INTERVAL);
}
stop() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
this.unsubscribeLogs();
this._detachFromWs();
}
isConnected() {
const ws = window.__ignisWs;
return ws && ws.readyState === WebSocket.OPEN;
}
on(type, callback) {
if (!this._callbacks.has(type)) {
this._callbacks.set(type, []);
}
this._callbacks.get(type).push(callback);
}
off(type, callback) {
const list = this._callbacks.get(type);
if (!list) {
return;
}
const idx = list.indexOf(callback);
if (idx !== -1) {
list.splice(idx, 1);
}
}
// Listen for raw WebSocket messages (not channel-filtered).
// Used by core-sync-guard to watch for file changes.
onRaw(callback) {
this._rawHandler = callback;
}
offRaw() {
this._rawHandler = null;
}
send(type, payload) {
const ws = window.__ignisWs;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, ...payload }));
}
}
// Subscribe to server log broadcasts for a vault.
// Sends the initial subscribe message and keeps the subscription alive.
subscribeLogs(vaultId) {
// If already subscribed to this vault, no-op.
if (this._logSubVaultId === vaultId && this._logSubInterval) {
return;
}
this.unsubscribeLogs();
this._logSubVaultId = vaultId;
this.send("subscribe-logs", { vaultId });
this._logSubInterval = setInterval(() => {
this.send("subscribe-logs", { vaultId });
}, LOG_KEEPALIVE_INTERVAL);
}
// Stop the log subscription keepalive.
unsubscribeLogs() {
if (this._logSubInterval) {
clearInterval(this._logSubInterval);
this._logSubInterval = null;
}
this._logSubVaultId = null;
}
_attachToWs() {
const ws = window.__ignisWs;
if (!ws || ws === this._currentWs) {
return;
}
this._detachFromWs();
this._currentWs = ws;
this._handler = (event) => {
try {
const msg = JSON.parse(event.data);
// Dispatch raw messages (for non-channel listeners like file watchers)
if (this._rawHandler) {
this._rawHandler(msg);
}
if (msg.channel !== CHANNEL) {
return;
}
const listeners = this._callbacks.get(msg.type);
if (listeners) {
for (const cb of listeners) {
cb(msg.payload);
}
}
} catch {}
};
ws.addEventListener("message", this._handler);
}
_detachFromWs() {
if (this._currentWs && this._handler) {
this._currentWs.removeEventListener("message", this._handler);
}
this._currentWs = null;
this._handler = null;
}
}
module.exports = { WsListener };

View File

@@ -0,0 +1,134 @@
.ignis-vault-list {
margin: 8px 0 16px;
}
.ignis-vault-row {
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
margin-bottom: 8px;
}
.ignis-vault-row-info {
flex: 1;
min-width: 0;
}
.ignis-vault-row-name {
font-weight: var(--font-semibold);
font-size: var(--font-ui-medium);
}
.ignis-vault-row-region {
font-size: var(--font-ui-small);
color: var(--text-muted);
}
.ignis-vault-connect-options {
padding: 8px 16px 16px;
margin-bottom: 8px;
border: 1px solid var(--background-modifier-border);
border-top: none;
border-radius: 0 0 var(--radius-s) var(--radius-s);
margin-top: -8px;
}
.ignis-vault-connect-options .setting-item {
border-top: none;
}
.ignis-sync-statusbar {
display: flex;
align-items: center;
cursor: pointer;
position: relative;
}
.ignis-sync-icon svg {
width: 14px;
height: 14px;
}
.ignis-sync-spinning svg {
animation: ignis-spin 1s linear infinite;
}
@keyframes ignis-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ignis-sync-synced svg {
color: var(--color-green);
}
.ignis-sync-syncing svg {
color: var(--interactive-accent);
}
.ignis-sync-error svg {
color: var(--color-red);
}
.ignis-sync-stopped svg {
color: var(--text-muted);
}
.ignis-sync-popover {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 4px;
background: var(--background-modifier-message);
color: var(--text-normal);
font-size: var(--font-ui-smaller);
padding: 4px 8px;
border-radius: var(--radius-s);
white-space: nowrap;
box-shadow: var(--shadow-s);
}
.ignis-log-details {
margin-top: 16px;
}
.ignis-log-details summary {
cursor: pointer;
color: var(--text-muted);
font-size: var(--font-ui-small);
padding: 4px 0;
user-select: none;
}
.ignis-log-details summary:hover {
color: var(--text-normal);
}
.ignis-log-terminal {
background: #1a1a1a;
color: #d4d4d4;
font-family: var(--font-monospace);
font-size: 12px;
line-height: 1.5;
padding: 12px;
border-radius: var(--radius-s);
max-height: 250px;
overflow-y: auto;
overflow-x: hidden;
margin-top: 8px;
white-space: pre-wrap;
word-break: break-all;
}
.ignis-log-terminal code {
background: none;
color: inherit;
font-size: inherit;
padding: 0;
}

View File

@@ -0,0 +1,230 @@
const auth = require("./auth");
const obCli = require("./ob-cli");
function mountRoutes(router, plugin) {
router.get("/status", (req, res) => {
const ctx = plugin.getCtx();
const obStatus = plugin.getObStatus();
const tokenInfo = auth.getTokenInfo(ctx.dataDir);
res.json({
installed: obStatus?.installed || false,
version: obStatus?.version || null,
authenticated: auth.isAuthenticated(ctx.dataDir),
email: tokenInfo?.email || null,
name: tokenInfo?.name || null,
});
});
router.post("/login", (req, res) => {
const ctx = plugin.getCtx();
const { token, email, name } = req.body;
if (!token) {
return res.status(400).json({ error: "Token is required" });
}
try {
auth.saveToken(ctx.dataDir, { token, email: email || null, name: name || null });
ctx.log(`Auth token saved${email ? ` for ${email}` : ""}`);
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post("/logout", (req, res) => {
const ctx = plugin.getCtx();
try {
auth.clearToken(ctx.dataDir);
ctx.log("Auth token cleared");
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.post("/setup", async (req, res) => {
const ctx = plugin.getCtx();
const syncManager = plugin.getSyncManager();
const { vaultId, remoteVault, remoteVaultName, vaultPassword, deviceName, mode } = req.body;
if (!vaultId || !remoteVault) {
return res.status(400).json({ error: "vaultId and remoteVault are required" });
}
if (!auth.isAuthenticated(ctx.dataDir)) {
return res.status(401).json({ error: "Not authenticated" });
}
const vaultPath = ctx.config.getVaultPath(vaultId);
if (!vaultPath) {
return res.status(404).json({ error: "Vault not found" });
}
try {
const state = await syncManager.setupSync(vaultId, vaultPath, remoteVault, {
remoteVaultName,
vaultPassword,
deviceName,
mode,
});
res.json({ success: true, state });
} catch (e) {
ctx.log(`Failed to setup sync: ${e.message}`);
res.status(500).json({ error: e.message });
}
});
router.post("/start", (req, res) => {
const ctx = plugin.getCtx();
const syncManager = plugin.getSyncManager();
const { vaultId } = req.body;
if (!vaultId) {
return res.status(400).json({ error: "vaultId is required" });
}
try {
const state = syncManager.startSync(vaultId);
res.json({ success: true, state });
} catch (e) {
ctx.log(`Failed to start sync: ${e.message}`);
res.status(500).json({ error: e.message });
}
});
router.post("/stop", (req, res) => {
const ctx = plugin.getCtx();
const syncManager = plugin.getSyncManager();
const { vaultId } = req.body;
if (!vaultId) {
return res.status(400).json({ error: "vaultId is required" });
}
try {
const state = syncManager.stopSync(vaultId);
res.json({ success: true, state });
} catch (e) {
ctx.log(`Failed to stop sync: ${e.message}`);
res.status(500).json({ error: e.message });
}
});
router.post("/unlink", async (req, res) => {
const ctx = plugin.getCtx();
const syncManager = plugin.getSyncManager();
const { vaultId } = req.body;
if (!vaultId) {
return res.status(400).json({ error: "vaultId is required" });
}
try {
await syncManager.unlinkVault(vaultId);
res.json({ success: true });
} catch (e) {
ctx.log(`Failed to unlink vault: ${e.message}`);
res.status(500).json({ error: e.message });
}
});
router.get("/logs", (req, res) => {
const syncManager = plugin.getSyncManager();
const { vaultId, limit } = req.query;
if (!vaultId) {
return res.status(400).json({ error: "vaultId is required" });
}
const logs = syncManager.getLogs(vaultId, limit ? parseInt(limit) : 100);
res.json({ logs });
});
router.get("/vaults", (req, res) => {
const syncManager = plugin.getSyncManager();
res.json({ vaults: syncManager.getAllStates() });
});
router.post("/create-remote-vault", async (req, res) => {
const ctx = plugin.getCtx();
const { name, encryption, password, region } = req.body;
if (!name) {
return res.status(400).json({ error: "name is required" });
}
if (!auth.isAuthenticated(ctx.dataDir)) {
return res.status(401).json({ error: "Not authenticated" });
}
const args = ["sync-create-remote", "--name", name];
if (encryption) {
args.push("--encryption", encryption);
}
if (password) {
args.push("--password", password);
}
if (region) {
args.push("--region", region);
}
try {
await obCli.runCommand(args);
ctx.log(`Created remote vault: ${name}`);
res.json({ success: true });
} catch (e) {
ctx.log(`Failed to create remote vault: ${e.message}`);
res.status(500).json({ error: e.message });
}
});
router.get("/remote-vaults", async (req, res) => {
const ctx = plugin.getCtx();
if (!auth.isAuthenticated(ctx.dataDir)) {
return res.status(401).json({ error: "Not authenticated" });
}
try {
const result = await obCli.runCommand(["sync-list-remote"]);
const vaults = parseRemoteVaults(result.stdout);
res.json({ vaults });
} catch (e) {
ctx.log(`Failed to list remote vaults: ${e.message}`);
res.status(500).json({ error: e.message });
}
});
}
function parseRemoteVaults(stdout) {
const lines = stdout.trim().split("\n");
const vaults = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("Available")) {
continue;
}
// Format: [vaultId] "[vaultName]" ([region])
const match = trimmed.match(/^([a-f0-9]+)\s+"([^"]+)"\s+\(([^)]+)\)/);
if (match) {
vaults.push({ id: match[1], name: match[2], region: match[3] });
}
}
return vaults;
}
module.exports = { mountRoutes };

View File

@@ -0,0 +1,371 @@
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, broadcaster) {
this.ctx = ctx;
this.broadcaster = broadcaster;
this.states = new Map();
this.stateFile = path.join(ctx.dataDir, "sync-states.json");
}
loadStates(vaults) {
try {
const saved = JSON.parse(fs.readFileSync(this.stateFile, "utf-8"));
for (const entry of saved) {
const vaultPath = vaults[entry.vaultId];
if (!vaultPath) {
this.ctx.log(`Skipping state for missing vault: ${entry.vaultId}`);
continue;
}
this.states.set(entry.vaultId, {
vaultId: entry.vaultId,
vaultPath,
remoteVault: entry.remoteVault,
remoteVaultName: entry.remoteVaultName || null,
status: "stopped",
pid: null,
lastActivity: new Date().toISOString(),
error: null,
config: entry.config || {
mode: "bidirectional",
deviceName: "ignis-headless",
},
autoStart: entry.autoStart || false,
logs: [],
_process: null,
});
}
this.ctx.log(`Loaded ${saved.length} sync configurations`);
} catch {
this.ctx.log("No previous sync states found");
}
}
saveStates() {
const data = [];
for (const [vaultId, state] of this.states) {
data.push({
vaultId: state.vaultId,
vaultPath: state.vaultPath,
remoteVault: state.remoteVault,
remoteVaultName: state.remoteVaultName,
config: state.config,
autoStart: state.autoStart,
});
}
fs.writeFileSync(this.stateFile, JSON.stringify(data, null, 2), "utf-8");
}
async setupSync(vaultId, vaultPath, remoteVault, options = {}) {
const args = ["sync-setup", "--vault", remoteVault, "--path", "."];
if (options.vaultPassword) {
args.push("--password", options.vaultPassword);
}
if (options.deviceName) {
args.push("--device-name", options.deviceName);
}
await runCommand(args, { cwd: vaultPath });
const state = {
vaultId,
vaultPath,
remoteVault,
remoteVaultName: options.remoteVaultName || null,
status: "stopped",
pid: null,
lastActivity: new Date().toISOString(),
error: null,
config: {
mode: options.mode || "bidirectional",
deviceName: options.deviceName || "ignis-headless",
},
autoStart: false,
logs: [],
_process: null,
};
this.states.set(vaultId, state);
this.saveStates();
this.ctx.log(`Sync setup complete for ${vaultId} -> ${remoteVault}`);
return this.getState(vaultId);
}
startSync(vaultId) {
const state = this.states.get(vaultId);
if (!state) {
throw new Error(`No sync configuration for vault: ${vaultId}`);
}
if (state.status === "running") {
this.ctx.log(`Sync already running for ${vaultId}`);
return this.getState(vaultId);
}
const args = ["sync", "--continuous"];
if (state.config.mode === "pull-only") {
args.push("--pull-only");
} else if (state.config.mode === "mirror-remote") {
args.push("--mirror-remote");
}
const proc = spawnOb(args, { cwd: state.vaultPath });
state.status = "running";
state.pid = proc.pid;
state.error = null;
state.autoStart = true;
state._process = proc;
this.addLog(state, `Sync started (pid: ${proc.pid})`);
proc.stdout.on("data", (data) => {
const lines = data.toString().split("\n");
for (const line of lines) {
if (line.trim()) {
this.addLog(state, line.trim());
state.lastActivity = new Date().toISOString();
this.broadcaster.broadcastLog(vaultId, line.trim());
}
}
});
proc.stderr.on("data", (data) => {
const lines = data.toString().split("\n");
for (const line of lines) {
if (line.trim()) {
this.addLog(state, `[stderr] ${line.trim()}`);
}
}
});
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;
if (code !== 0) {
state.error = `Process exited with code ${code}`;
this.addLog(state, `Sync exited with code ${code}`);
} else {
this.addLog(state, "Sync stopped");
}
this.ctx.log(`Sync stopped for ${vaultId} (code: ${code})`);
this.broadcaster.broadcastStatus(this.getState(vaultId));
this.saveStates();
});
proc.on("error", (err) => {
state.status = "error";
state.error = err.message;
state.pid = null;
state._process = null;
this.addLog(state, `Error: ${err.message}`);
this.ctx.log(`Sync error for ${vaultId}: ${err.message}`);
this.broadcaster.broadcastStatus(this.getState(vaultId));
this.saveStates();
});
this.broadcaster.broadcastStatus(this.getState(vaultId));
this.ctx.log(`Started sync for ${vaultId} (pid: ${proc.pid})`);
this.saveStates();
return this.getState(vaultId);
}
stopSync(vaultId) {
const state = this.states.get(vaultId);
if (!state || !state._process) {
throw new Error(`No active sync for vault: ${vaultId}`);
}
state._userStopped = true;
killProcess(state._process);
state.status = "stopped";
state.pid = null;
state.autoStart = false;
state._process = null;
this.addLog(state, "Sync stopped by user");
this.ctx.log(`Stopped sync for ${vaultId}`);
this.broadcaster.broadcastStatus(this.getState(vaultId));
this.saveStates();
return this.getState(vaultId);
}
async unlinkVault(vaultId) {
const state = this.states.get(vaultId);
if (!state) {
throw new Error(`No sync configuration for vault: ${vaultId}`);
}
if (state._process) {
state._userStopped = true;
killProcess(state._process);
}
// Tell ob to disconnect from the remote vault and clear its stored config
try {
await runCommand(["sync-unlink", "--path", state.vaultPath]);
this.ctx.log(`ob sync-unlink completed for ${vaultId}`);
} catch (e) {
this.ctx.log(`ob sync-unlink failed for ${vaultId}: ${e.message}`);
}
this.states.delete(vaultId);
this.saveStates();
this.ctx.log(`Unlinked vault ${vaultId}`);
}
getState(vaultId) {
const state = this.states.get(vaultId);
if (!state) {
return null;
}
return {
vaultId: state.vaultId,
remoteVault: state.remoteVault,
remoteVaultName: state.remoteVaultName,
status: state.status,
pid: state.pid,
lastActivity: state.lastActivity,
error: state.error,
config: state.config,
autoStart: state.autoStart,
};
}
getAllStates() {
const result = [];
for (const [vaultId] of this.states) {
result.push(this.getState(vaultId));
}
return result;
}
getLogs(vaultId, limit = 100) {
const state = this.states.get(vaultId);
if (!state) {
return [];
}
return state.logs.slice(-limit);
}
addLog(state, line) {
state.logs.push({
timestamp: new Date().toISOString(),
line,
});
if (state.logs.length > MAX_LOG_ENTRIES) {
state.logs = state.logs.slice(-MAX_LOG_ENTRIES);
}
}
autoStartAll() {
let started = 0;
for (const [vaultId, state] of this.states) {
if (state.autoStart && state.status === "stopped") {
try {
this.startSync(vaultId);
started++;
} catch (e) {
this.ctx.log(`Auto-start failed for ${vaultId}: ${e.message}`);
}
}
}
if (started > 0) {
this.ctx.log(`Auto-started sync for ${started} vault(s)`);
}
}
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 {
killProcess(proc);
} catch (e) {
this.ctx.log(`Error stopping sync for ${vaultId}: ${e.message}`);
}
}
}
if (waitPromises.length > 0) {
await Promise.all(waitPromises);
}
this.saveStates();
}
}
module.exports = { SyncManager };