diff --git a/.gitignore b/.gitignore index 6e55859..96243e3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ investigation/ vaults/ plugin/main.js +server/plugins/*/plugin/main.js diff --git a/server/plugins/headless-sync/ob-cli.js b/server/plugins/headless-sync/ob-cli.js index 17faf55..5bcd51f 100644 --- a/server/plugins/headless-sync/ob-cli.js +++ b/server/plugins/headless-sync/ob-cli.js @@ -1,29 +1,38 @@ const { spawn, execSync } = require("child_process"); const os = require("os"); +const isWindows = process.platform === "win32"; + function checkInstalled() { try { - const output = execSync("ob --version", { stdio: "pipe" }).toString().trim(); + 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 = {}) { + return spawn("ob", args, { + env: { ...process.env, HOME: os.homedir() }, + shell: isWindows, + windowsHide: true, + ...opts, + }); +} + function runCommand(args, opts = {}) { return new Promise((resolve, reject) => { - const spawnOpts = { - env: { ...process.env, HOME: os.homedir() }, - }; - - if (opts.cwd) { - spawnOpts.cwd = opts.cwd; - } - let stdout = ""; let stderr = ""; - const proc = spawn("ob", args, spawnOpts); + const proc = spawnOb(args, opts); proc.stdout.on("data", (data) => { stdout += data.toString(); @@ -49,4 +58,4 @@ function runCommand(args, opts = {}) { }); } -module.exports = { checkInstalled, runCommand }; +module.exports = { checkInstalled, spawnOb, runCommand }; diff --git a/server/plugins/headless-sync/plugin/src/api.js b/server/plugins/headless-sync/plugin/src/api.js index a4e145c..4b0c848 100644 --- a/server/plugins/headless-sync/plugin/src/api.js +++ b/server/plugins/headless-sync/plugin/src/api.js @@ -39,6 +39,10 @@ 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 }); } @@ -47,6 +51,10 @@ function stopSync(vaultId) { return post("/stop", { vaultId }); } +function unlinkVault(vaultId) { + return post("/unlink", { vaultId }); +} + function getVaults() { return fetchJson("/vaults"); } @@ -61,8 +69,10 @@ module.exports = { logout, getRemoteVaults, setupSync, + createRemoteVault, startSync, stopSync, + unlinkVault, getVaults, getLogs, }; diff --git a/server/plugins/headless-sync/plugin/src/settings-tab.js b/server/plugins/headless-sync/plugin/src/settings-tab.js index 4b07e60..0ec8357 100644 --- a/server/plugins/headless-sync/plugin/src/settings-tab.js +++ b/server/plugins/headless-sync/plugin/src/settings-tab.js @@ -12,8 +12,6 @@ class HeadlessSyncSettingTab extends PluginSettingTab { const { containerEl } = this; containerEl.empty(); - containerEl.createEl("h2", { text: "Headless Sync" }); - let serverStatus; try { @@ -35,10 +33,7 @@ class HeadlessSyncSettingTab extends PluginSettingTab { } this.renderAuthSection(containerEl, serverStatus); - - if (serverStatus.authenticated) { - await this.renderSyncSection(containerEl); - } + await this.renderSyncSection(containerEl, serverStatus.authenticated); } renderAuthSection(containerEl, serverStatus) { @@ -52,18 +47,17 @@ class HeadlessSyncSettingTab extends PluginSettingTab { `Signed in as ${serverStatus.name || "unknown"} (${serverStatus.email || "unknown"})`, ) .addButton((btn) => { - btn - .setButtonText("Disconnect") - .setWarning() - .onClick(async () => { - try { - await api.logout(); - new Notice("Disconnected from Headless Sync"); - this.display(); - } catch (e) { - new Notice(`Failed to disconnect: ${e.message}`); - } - }); + btn.setButtonText("Disconnect"); + btn.buttonEl.addClass("mod-destructive"); + btn.onClick(async () => { + try { + await api.logout(); + new Notice("Disconnected from Headless Sync"); + this.display(); + } catch (e) { + new Notice(`Failed to disconnect: ${e.message}`); + } + }); }); } else if (localToken) { // State B: signed into Obsidian, not connected to server @@ -111,7 +105,21 @@ class HeadlessSyncSettingTab extends PluginSettingTab { } } - async renderSyncSection(containerEl) { + async renderSyncSection(containerEl, authenticated) { + containerEl.createEl("h3", { text: "Vault sync" }); + + if (!authenticated) { + new Setting(containerEl) + .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; @@ -128,8 +136,6 @@ class HeadlessSyncSettingTab extends PluginSettingTab { const vaultState = vaultsData.vaults.find((v) => v.vaultId === vaultId); - containerEl.createEl("h3", { text: "Vault sync" }); - if (!vaultState) { new Setting(containerEl) .setName("Sync not configured") @@ -139,7 +145,30 @@ class HeadlessSyncSettingTab extends PluginSettingTab { .setButtonText("Set up sync") .setCta() .onClick(() => { - new Notice("Vault picker coming soon."); + 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: () => { + cleanup(); + modal.$destroy(); + this.display(); + }, + }, + }); + + modal.$on("close", () => { + cleanup(); + modal.$destroy(); + }); }); }); @@ -149,7 +178,20 @@ class HeadlessSyncSettingTab extends PluginSettingTab { // Show current sync config new Setting(containerEl) .setName("Remote vault") - .setDesc(vaultState.remoteVault || "unknown"); + .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"); + this.display(); + } catch (e) { + new Notice(`Failed to unlink: ${e.message}`); + } + }); + }); new Setting(containerEl) .setName("Sync mode") @@ -168,7 +210,9 @@ class HeadlessSyncSettingTab extends PluginSettingTab { .setDesc(statusText) .addButton((btn) => { if (vaultState.status === "running") { - btn.setButtonText("Stop sync").setWarning().onClick(async () => { + btn.setButtonText("Stop sync"); + btn.buttonEl.addClass("mod-destructive"); + btn.onClick(async () => { try { await api.stopSync(vaultId); new Notice("Sync stopped"); diff --git a/server/plugins/headless-sync/plugin/styles.css b/server/plugins/headless-sync/plugin/styles.css new file mode 100644 index 0000000..09a6da5 --- /dev/null +++ b/server/plugins/headless-sync/plugin/styles.css @@ -0,0 +1,40 @@ +.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; +} diff --git a/server/plugins/headless-sync/routes.js b/server/plugins/headless-sync/routes.js index 010a19e..2bc9d84 100644 --- a/server/plugins/headless-sync/routes.js +++ b/server/plugins/headless-sync/routes.js @@ -49,7 +49,7 @@ function mountRoutes(router, plugin) { router.post("/setup", async (req, res) => { const ctx = plugin.getCtx(); const syncManager = plugin.getSyncManager(); - const { vaultId, remoteVault, vaultPassword, deviceName, mode } = req.body; + const { vaultId, remoteVault, remoteVaultName, vaultPassword, deviceName, mode } = req.body; if (!vaultId || !remoteVault) { return res.status(400).json({ error: "vaultId and remoteVault are required" }); @@ -67,6 +67,7 @@ function mountRoutes(router, plugin) { try { const state = await syncManager.setupSync(vaultId, vaultPath, remoteVault, { + remoteVaultName, vaultPassword, deviceName, mode, @@ -115,6 +116,24 @@ function mountRoutes(router, plugin) { } }); + router.post("/unlink", (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 { + 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; @@ -132,6 +151,42 @@ function mountRoutes(router, plugin) { 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(); @@ -162,10 +217,10 @@ function parseRemoteVaults(stdout) { } // Format: [vaultId] "[vaultName]" ([region]) - const match = trimmed.match(/^([a-f0-9]+)\s+"([^"]+)"/); + const match = trimmed.match(/^([a-f0-9]+)\s+"([^"]+)"\s+\(([^)]+)\)/); if (match) { - vaults.push({ id: match[1], name: match[2] }); + vaults.push({ id: match[1], name: match[2], region: match[3] }); } } diff --git a/server/plugins/headless-sync/sync-manager.js b/server/plugins/headless-sync/sync-manager.js index 34390f7..258002e 100644 --- a/server/plugins/headless-sync/sync-manager.js +++ b/server/plugins/headless-sync/sync-manager.js @@ -1,7 +1,6 @@ -const { spawn } = require("child_process"); const fs = require("fs"); const path = require("path"); -const os = require("os"); +const { spawnOb } = require("./ob-cli"); const MAX_LOG_ENTRIES = 200; @@ -28,6 +27,7 @@ class SyncManager { vaultId: entry.vaultId, vaultPath, remoteVault: entry.remoteVault, + remoteVaultName: entry.remoteVaultName || null, status: "stopped", pid: null, lastActivity: new Date().toISOString(), @@ -56,6 +56,7 @@ class SyncManager { vaultId: state.vaultId, vaultPath: state.vaultPath, remoteVault: state.remoteVault, + remoteVaultName: state.remoteVaultName, config: state.config, autoStart: state.autoStart, }); @@ -83,6 +84,7 @@ class SyncManager { vaultId, vaultPath, remoteVault, + remoteVaultName: options.remoteVaultName || null, status: "stopped", pid: null, lastActivity: new Date().toISOString(), @@ -123,10 +125,7 @@ class SyncManager { args.push("--mirror-remote"); } - const proc = spawn("ob", args, { - cwd: state.vaultPath, - env: { ...process.env, HOME: os.homedir() }, - }); + const proc = spawnOb(args, { cwd: state.vaultPath }); state.status = "running"; state.pid = proc.pid; @@ -214,6 +213,22 @@ class SyncManager { return this.getState(vaultId); } + unlinkVault(vaultId) { + const state = this.states.get(vaultId); + + if (!state) { + throw new Error(`No sync configuration for vault: ${vaultId}`); + } + + if (state._process) { + state._process.kill("SIGTERM"); + } + + this.states.delete(vaultId); + this.saveStates(); + this.ctx.log(`Unlinked vault ${vaultId}`); + } + getState(vaultId) { const state = this.states.get(vaultId); @@ -224,6 +239,7 @@ class SyncManager { return { vaultId: state.vaultId, remoteVault: state.remoteVault, + remoteVaultName: state.remoteVaultName, status: state.status, pid: state.pid, lastActivity: state.lastActivity, diff --git a/src/ui/index.js b/src/ui/index.js index 6fdb228..9d9392f 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -3,3 +3,4 @@ export { default as MessageDialog } from "./components/layout/MessageDialog.svel export { default as ConfirmDialog } from "./components/layout/ConfirmDialog.svelte"; export { default as PromptDialog } from "./components/layout/PromptDialog.svelte"; export { default as PluginInstallDialog } from "./components/layout/PluginInstallDialog.svelte"; +export { default as SyncSetupModal } from "./views/SyncSetupModal.svelte"; diff --git a/src/ui/views/SyncSetupModal.svelte b/src/ui/views/SyncSetupModal.svelte new file mode 100644 index 0000000..c75e9cb --- /dev/null +++ b/src/ui/views/SyncSetupModal.svelte @@ -0,0 +1,154 @@ + + + +
+ {#if view === "list"} +

+ Link this vault to an Obsidian Sync remote vault for server-side synchronization. +

+ + {#if error} +
+

Failed to load remote vaults: {error}

+ +
+ {:else} + (view = "create")} /> + {/if} + {:else} +

+ Create a new remote vault on Obsidian Sync. +

+ + (view = "list")} /> + {/if} +
+
+ + diff --git a/src/ui/views/sync/CreateVaultForm.svelte b/src/ui/views/sync/CreateVaultForm.svelte new file mode 100644 index 0000000..28e2149 --- /dev/null +++ b/src/ui/views/sync/CreateVaultForm.svelte @@ -0,0 +1,185 @@ + + +
+
+
+
Vault name
+
Helps you remember what this vault is for
+
+ +
+ +
+
+
Region
+
Select the server region closest to you
+
+ +
+ +
+
+
Encryption
+
+ End-to-end encryption requires a password you must remember. + This cannot be changed later. +
+
+ +
+ + {#if encryption === "e2ee"} +
+
+
Encryption password
+
+ If you forget this password, any remote data will remain unusable forever. + This does not affect your local data. +
+
+ +
+ {/if} + + {#if error} +
{error}
+ {/if} + + +
+ + diff --git a/src/ui/views/sync/VaultList.svelte b/src/ui/views/sync/VaultList.svelte new file mode 100644 index 0000000..d5960dd --- /dev/null +++ b/src/ui/views/sync/VaultList.svelte @@ -0,0 +1,94 @@ + + +
+

Your remote vaults

+ +
+ {#if loading} +
+
+
+ {:else if vaults.length === 0} +

No remote vaults found. Create one to get started.

+ {:else} + {#each vaults as vault (vault.id)} + + {/each} + {/if} +
+ + +
+ + diff --git a/src/ui/views/sync/VaultRow.svelte b/src/ui/views/sync/VaultRow.svelte new file mode 100644 index 0000000..e58686f --- /dev/null +++ b/src/ui/views/sync/VaultRow.svelte @@ -0,0 +1,229 @@ + + +
+
+
+
{vault.name}
+
{vault.region || "Unknown region"}
+
+
+ {#if linked} + + {:else} + + {/if} +
+
+ + {#if expanded} +
+
+
+
Vault password
+
Required if the vault uses end-to-end encryption
+
+ +
+ +
+
+
Device name
+
Identifies this server in sync version history
+
+ +
+ +
+
+
Sync mode
+
+ +
+ + +
+ {/if} +
+ +