diff --git a/openspec/changes/add-desktop-local-remote-modes/design.md b/openspec/changes/add-desktop-local-remote-modes/design.md index 9bf8f43..77c174b 100644 --- a/openspec/changes/add-desktop-local-remote-modes/design.md +++ b/openspec/changes/add-desktop-local-remote-modes/design.md @@ -356,6 +356,22 @@ chain of discoveries, each committed as a fix: binary?** (decision 4) The main server's port is auto-selected per launch, so a stable MCP URL for a statically-configured agent host depends on this. If not, fall back to publishing the per-launch URL via `agent.json`. +- **Bindings invocation is broken in the CEF backend (upstream, unreported).** + Spiked for the Disconnect action (task 7.4): the `bindings` proxy IS injected + into every page including foreign https origins — so "am I inside the + Quantum desktop shell?" detection works from the server's own Settings page — + but calling a bound name fails with "No callback bound for: " from + both page-world and executeJs, on the adopted startup window and a freshly + constructed one alike (deno 2.9.3, laufey cef 0.5.0, Windows). The name + propagates to the page shim after a reload (the pre-bind error differs: + "No binding for ''"), so registration reaches the shim but the + Deno-side handler table misses — likely a laufey CEF bug; their tracker has + no matching issue (the known #11 null-return bindings bug was fixed in + 0.3.2, before our 0.5.0). Plan: local-mode Disconnect ships bindings-free + (the Settings page is served in-process); remote-mode Disconnect renders + behind the `bindings`-presence check and starts working when upstream fixes + invocation. Fallback if that stalls: a fixed-port loopback shell API with a + CORS `/disconnect` (which would also settle the fixed-port open question). - **CEF profile persistence (upstream).** The webview's cookie jar does not survive relaunch (see Resolved During Implementation), so remote mode re-authenticates every launch. Watch `deno desktop` for a persistent-profile diff --git a/openspec/changes/add-desktop-local-remote-modes/tasks.md b/openspec/changes/add-desktop-local-remote-modes/tasks.md index dd3c0a3..85b3a21 100644 --- a/openspec/changes/add-desktop-local-remote-modes/tasks.md +++ b/openspec/changes/add-desktop-local-remote-modes/tasks.md @@ -232,9 +232,24 @@ the former tasks 6.1/6.3/6.4/6.5 are dropped. (See design decision 5.) render inline with the input preserved; a valid address lands the browser on the remote origin; every subsequent request to the embedded server redirects there. -- [ ] 7.4 Add the reset action (Settings) that clears app-config and returns to +- [x] 7.4 Add the reset action (Settings) that clears app-config and returns to the mode-selection screen on next launch. Confirm with a plain sentence - about what reset does and does not delete (local data stays on disk). + about what reset does and does not delete (local data stays on disk). — + Shipped as **Disconnect** (renamed: nothing is deleted), a Settings + section with a two-step inline confirm, effective immediately rather + than on next launch. Two variants: in the desktop build's own pages + (local mode) it is a server action — `disarmLocalMode()` (stops the + 6-hour sync interval and shuts down the loopback MCP listener so a + shell switching to remote never keeps syncing the abandoned ledger), + clears app-config, 303 → `/setup`. On a hosted server's pages viewed + through the desktop shell (remote mode), the section renders when the + webview's injected `bindings` proxy is detected and calls + `bindings.quantumDisconnect()` — registered by `registerDesktopShell` + in the shell, which clears config and navigates the window home; + currently fails gracefully with a manual-fallback message because + laufey cef 0.5.0 never populates the binding callback table (see + design's open question). Local machinery moved from `hooks.server.ts` + to `src/lib/server/desktop-runtime.ts` (arm/disarm; re-armable). ## 8. Verification @@ -262,8 +277,16 @@ the former tasks 6.1/6.3/6.4/6.5 are dropped. (See design decision 5.) predicted by the profile spike, the session did not survive an app relaunch (ephemeral CEF profile — see design's open question); login runs again each launch. -- [ ] 8.4 Reset from each mode returns to the selection screen; local data files - remain on disk after a reset. +- [~] 8.4 Reset from each mode returns to the selection screen; local data files + remain on disk after a reset. — Local mode verified end to end (headless and + in-browser): fresh → Local → welcome → Settings → Disconnect → setup screen + in-session; `quantum-desktop.json` cleared, database files intact; loopback + MCP provably stopped (connection refused) and re-armed (401, token-gated) + after re-choosing Local, which lands straight on the dashboard with data + intact. Remote-mode in-app disconnect is blocked on the upstream laufey + bindings bug — the button renders and degrades to a manual-fallback message; + re-verify once upstream fixes invocation (or the server is updated and a + packaged shell drives it live). - [x] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with server config, and that the default (no `QUANTUM_MODE`) behaves exactly as the current release. — Covered by `config.test.ts`: local + `ALLOWED_DIDS` diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 366d346..51e9672 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -12,28 +12,20 @@ import { deleteExpiredSessions, getSessionUser, } from "$lib/server/services/sessions"; -import { verifyToken } from "$lib/server/services/api-tokens"; -import { getUser, upsertUser } from "$lib/server/services/users"; +import { readBearer, verifyToken } from "$lib/server/services/api-tokens"; +import { getUser } from "$lib/server/services/users"; import { readDesktopConfig } from "$lib/server/desktop-config"; +import { + armLocalMode, + LOCAL_DID, + logSyncOutcomes, + registerDesktopShell, +} from "$lib/server/desktop-runtime"; import { runSync } from "$lib/server/services/sync"; -import { handleMcpRequest } from "$lib/server/mcp/server"; -import process from "node:process"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -/** The synthetic single user of a local desktop build (no login). */ -export const LOCAL_DID = "did:local:self"; +import { isMcpPath } from "$lib/server/mcp/server"; -/** Log the outcome of a sync run to the console. */ -function logSyncOutcomes(outcomes: Awaited>): void { - for (const o of outcomes) { - console.log( - `sync connection ${o.connectionId}: ${ - o.ok ? "ok" : `FAILED (${o.error})` - }, +${o.newTransactions} txns, ${o.reconciled} reconciled, ${o.ruleCategorized} rule-categorized`, - ); - } -} +// Re-exported for routes that seed or resolve the local user (e.g. /welcome). +export { LOCAL_DID }; export const init: ServerInit = async () => { if (building) return; @@ -47,6 +39,9 @@ export const init: ServerInit = async () => { // chosen mid-session on /setup, handleLocal arms this lazily. const chosen = readDesktopConfig(config.dbPath); if (chosen?.mode === "local") armLocalMode(config); + // Adopt the window and expose bindings.quantumDisconnect to every + // page the webview shows, whichever mode is (or becomes) chosen. + registerDesktopShell(config); } else { armLocalMode(config); } @@ -67,81 +62,6 @@ export const init: ServerInit = async () => { } }; -// Local mode is not always on, and Deno.cron keeps its schedule in memory with -// no catch-up for missed fires, so a fixed daily time would silently skip any -// day the app wasn't open. Instead: sync once on launch (catching up whatever -// was missed while closed) and periodically while the app runs. -const LOCAL_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours - -let localArmed = false; - -/** - * Bring up local-mode machinery exactly once: open (and create) the database, - * seed the local identity, start the catch-up/interval sync and the optional - * loopback MCP listener. Called from `init` when local mode is already chosen, - * or from `handleLocal` the moment the user chooses it on /setup — so the - * choice takes effect without a relaunch, and remote mode never runs any of it. - */ -function armLocalMode(config: ReturnType): void { - if (localArmed) return; - localArmed = true; - const db = initDb(config.dbPath); - if (config.localDisplayName) { - upsertUser(db, LOCAL_DID, config.localDisplayName); - } - startLocalSync(); - startLoopbackMcp(config.dbPath); -} - -function startLocalSync(): void { - const run = () => - runSync(getDb()) - .then(logSyncOutcomes) - .catch((err) => console.warn("local sync failed:", err)); - run(); // on launch - setInterval(run, LOCAL_SYNC_INTERVAL_MS); // while running -} - -// A `deno desktop` build talks to its webview over an in-process channel, so -// the embedded server's HTTP port is not guaranteed to be a reachable surface -// for a local agent. When the desktop entrypoint sets QUANTUM_LOCAL_MCP_PORT, -// bind a loopback-only listener that mounts the same transport-only MCP handler -// and requires the same bearer token. Left unset (dev/server), the SvelteKit -// /mcp route serves agents directly and this is a no-op. -function startLoopbackMcp(dbPath: string): void { - const portRaw = process.env.QUANTUM_LOCAL_MCP_PORT?.trim(); - if (!portRaw) return; - const port = Number(portRaw); - if (!Number.isInteger(port) || port <= 0) { - console.warn(`QUANTUM_LOCAL_MCP_PORT is not a valid port: ${portRaw}`); - return; - } - Deno.serve({ port, hostname: "127.0.0.1" }, (request) => { - const path = new URL(request.url).pathname; - if (!isMcpPath(path)) return new Response("Not found", { status: 404 }); - const bearer = readBearer(request.headers.get("authorization")); - const principal = bearer ? verifyToken(getDb(), bearer) : null; - if (!principal) return new Response("Unauthorized", { status: 401 }); - return handleMcpRequest(request, getDb(), principal); - }); - const mcpUrl = `http://127.0.0.1:${port}/mcp`; - console.log(`local MCP listening on ${mcpUrl}`); - - // Publish the endpoint next to the database so a local agent host can be - // pointed at it in one step. The URL only — never a token; the user mints a - // scoped, revocable token in Settings, so no plaintext credential sits on disk. - try { - const dir = dirname(dbPath); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, "agent.json"), - JSON.stringify({ mcpUrl }, null, 2), - ); - } catch (err) { - console.warn("could not write agent.json endpoint file:", err); - } -} - export const SESSION_COOKIE = "quantum_session"; /** Routes reachable without a session: login, OAuth plumbing, health check. */ @@ -153,17 +73,6 @@ const PUBLIC_PATHS = new Set([ "/healthz", ]); -/** Extract a bearer token from an Authorization header, or null. */ -function readBearer(header: string | null): string | null { - if (!header) return null; - const match = header.match(/^Bearer\s+(.+)$/i); - return match ? match[1].trim() : null; -} - -function isMcpPath(path: string): boolean { - return path === "/mcp" || path.startsWith("/mcp/"); -} - export const handle: Handle = ({ event, resolve }) => getConfig().mode === "local" ? handleLocal(event, resolve) diff --git a/src/lib/server/desktop-runtime.ts b/src/lib/server/desktop-runtime.ts new file mode 100644 index 0000000..daf2e94 --- /dev/null +++ b/src/lib/server/desktop-runtime.ts @@ -0,0 +1,159 @@ +import process from "node:process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { getDb, initDb } from "./db.ts"; +import { upsertUser } from "./services/users.ts"; +import { runSync } from "./services/sync.ts"; +import { readBearer, verifyToken } from "./services/api-tokens.ts"; +import { handleMcpRequest, isMcpPath } from "./mcp/server.ts"; +import { clearDesktopConfig } from "./desktop-config.ts"; +import type { Config } from "./config.ts"; + +// Desktop/local-mode runtime machinery (add-desktop-local-remote-modes): the +// sync cadence, the optional loopback MCP listener, and the shell-side pieces +// of the Disconnect action. Lives outside hooks.server.ts so the Settings +// route can disarm it without importing the hook module. + +/** The synthetic single user of a local desktop build (no login). */ +export const LOCAL_DID = "did:local:self"; + +// Local mode is not always on, and Deno.cron keeps its schedule in memory with +// no catch-up for missed fires, so a fixed daily time would silently skip any +// day the app wasn't open. Instead: sync once on launch (catching up whatever +// was missed while closed) and periodically while the app runs. Spike 1.1 +// confirmed the interval fires while the window is minimized. +const LOCAL_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours + +let localArmed = false; +let syncTimer: ReturnType | null = null; +let loopbackServer: { shutdown(): Promise } | null = null; + +/** Log the outcome of a sync run to the console. */ +export function logSyncOutcomes( + outcomes: Awaited>, +): void { + for (const o of outcomes) { + console.log( + `sync connection ${o.connectionId}: ${ + o.ok ? "ok" : `FAILED (${o.error})` + }, +${o.newTransactions} txns, ${o.reconciled} reconciled, ${o.ruleCategorized} rule-categorized`, + ); + } +} + +/** + * Bring up local-mode machinery exactly once: open (and create) the database, + * seed the local identity, start the catch-up/interval sync and the optional + * loopback MCP listener. Called from `init` when local mode is already chosen, + * or from the hook the moment the user chooses it on /setup — so the choice + * takes effect without a relaunch, and remote mode never runs any of it. + * Re-armable after `disarmLocalMode` (Disconnect, then choosing Local again). + */ +export function armLocalMode(config: Config): void { + if (localArmed) return; + localArmed = true; + const db = initDb(config.dbPath); + if (config.localDisplayName) { + upsertUser(db, LOCAL_DID, config.localDisplayName); + } + startLocalSync(); + startLoopbackMcp(config.dbPath); +} + +/** + * The teardown half of Disconnect: stop the sync interval and the loopback + * listener so a shell that switches to remote mode does not keep syncing the + * abandoned local database against the SimpleFIN Bridge. The open database + * handle is left alone — the file stays on disk, and re-choosing Local + * re-arms against it. + */ +export function disarmLocalMode(): void { + if (!localArmed) return; + localArmed = false; + if (syncTimer !== null) { + clearInterval(syncTimer); + syncTimer = null; + } + if (loopbackServer !== null) { + loopbackServer.shutdown(); + loopbackServer = null; + } +} + +function startLocalSync(): void { + const run = () => + runSync(getDb()) + .then(logSyncOutcomes) + .catch((err) => console.warn("local sync failed:", err)); + run(); // on launch + syncTimer = setInterval(run, LOCAL_SYNC_INTERVAL_MS); // while running +} + +// A `deno desktop` build talks to its webview over an in-process channel, so +// the embedded server's HTTP port is not guaranteed to be a reachable surface +// for a local agent. When the desktop entrypoint sets QUANTUM_LOCAL_MCP_PORT, +// bind a loopback-only listener that mounts the same transport-only MCP handler +// and requires the same bearer token. Left unset (dev/server), the SvelteKit +// /mcp route serves agents directly and this is a no-op. +function startLoopbackMcp(dbPath: string): void { + const portRaw = process.env.QUANTUM_LOCAL_MCP_PORT?.trim(); + if (!portRaw) return; + const port = Number(portRaw); + if (!Number.isInteger(port) || port <= 0) { + console.warn(`QUANTUM_LOCAL_MCP_PORT is not a valid port: ${portRaw}`); + return; + } + loopbackServer = Deno.serve({ port, hostname: "127.0.0.1" }, (request) => { + const path = new URL(request.url).pathname; + if (!isMcpPath(path)) return new Response("Not found", { status: 404 }); + const bearer = readBearer(request.headers.get("authorization")); + const principal = bearer ? verifyToken(getDb(), bearer) : null; + if (!principal) return new Response("Unauthorized", { status: 401 }); + return handleMcpRequest(request, getDb(), principal); + }); + const mcpUrl = `http://127.0.0.1:${port}/mcp`; + console.log(`local MCP listening on ${mcpUrl}`); + + // Publish the endpoint next to the database so a local agent host can be + // pointed at it in one step. The URL only — never a token; the user mints a + // scoped, revocable token in Settings, so no plaintext credential sits on disk. + try { + const dir = dirname(dbPath); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "agent.json"), + JSON.stringify({ mcpUrl }, null, 2), + ); + } catch (err) { + console.warn("could not write agent.json endpoint file:", err); + } +} + +/** + * Shell-side half of the Disconnect action: adopt the desktop window and + * expose `bindings.quantumDisconnect()` to every page the webview shows — + * including the remote server's own Settings page, which detects the shell by + * the presence of the `bindings` proxy. No-op outside a packaged desktop app + * (dev runs under plain `deno run` have no Deno.BrowserWindow). + * + * KNOWN UPSTREAM GAP: laufey cef 0.5.0 never populates the callback table, so + * invocations currently fail with "No callback bound" (see design.md open + * questions). The registration is correct and lights up when that is fixed. + */ +export function registerDesktopShell(config: Config): void { + // deno-lint-ignore no-explicit-any + const BrowserWindow = (Deno as any).BrowserWindow; + if (typeof BrowserWindow !== "function") return; + try { + const win = new BrowserWindow({}); // first construction adopts the startup window + win.bind("quantumDisconnect", () => { + clearDesktopConfig(config.dbPath); + disarmLocalMode(); + const port = process.env.DENO_SERVE_ADDRESS?.split(":").pop(); + if (port) win.navigate(`http://127.0.0.1:${port}/setup`); + return { ok: true }; + }); + } catch (err) { + console.warn("desktop shell setup failed:", err); + } +} diff --git a/src/lib/server/mcp/server.ts b/src/lib/server/mcp/server.ts index 7b218b9..8dd0cfb 100644 --- a/src/lib/server/mcp/server.ts +++ b/src/lib/server/mcp/server.ts @@ -307,6 +307,11 @@ export function buildMcpServer( return server; } +/** Whether a path targets the MCP endpoint. */ +export function isMcpPath(path: string): boolean { + return path === "/mcp" || path.startsWith("/mcp/"); +} + /** * Handle one MCP request: build a per-request server (stateless), connect it to * a web-standard transport, and let the transport produce the Response. Pure diff --git a/src/lib/server/services/api-tokens.ts b/src/lib/server/services/api-tokens.ts index d89e5f1..1e9f3c9 100644 --- a/src/lib/server/services/api-tokens.ts +++ b/src/lib/server/services/api-tokens.ts @@ -32,6 +32,13 @@ function hashToken(token: string): string { return createHash("sha256").update(token).digest("base64url"); } +/** Extract a bearer token from an Authorization header, or null. */ +export function readBearer(header: string | null): string | null { + if (!header) return null; + const match = header.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : null; +} + /** * Mint a token. Returns the one-time plaintext (never recoverable afterward) * alongside the stored summary. `userDid` is the creator on a multi-user server diff --git a/src/routes/(app)/settings/+page.server.ts b/src/routes/(app)/settings/+page.server.ts index 30ed04f..4c7911d 100644 --- a/src/routes/(app)/settings/+page.server.ts +++ b/src/routes/(app)/settings/+page.server.ts @@ -1,6 +1,8 @@ -import { fail } from "@sveltejs/kit"; +import { fail, redirect } from "@sveltejs/kit"; import { getDb } from "$lib/server/db"; import { getConfig } from "$lib/server/config"; +import { clearDesktopConfig } from "$lib/server/desktop-config"; +import { disarmLocalMode } from "$lib/server/desktop-runtime"; import { claimSetupToken, listConnections, @@ -34,6 +36,10 @@ export const load: PageServerLoad = () => { categories: listCategories(db), apiTokens: listTokens(db), appUrl: getConfig().appUrl, + // True only inside the desktop build serving its own pages (local mode). + // The hosted server's pages instead detect the desktop shell client-side, + // via the webview's injected `bindings` proxy. + desktopShell: getConfig().desktop === true, }; }; @@ -155,4 +161,17 @@ export const actions: Actions = { revokeToken(getDb(), Number(form.get("id"))); return { tokenMessage: "Token revoked." }; }, + + // Disconnect (desktop local mode): forget the mode choice and return to the + // setup screen. Deletes nothing — the local database stays on disk, and + // choosing Local again reconnects to it. The sync interval and loopback MCP + // are stopped so a shell that switches to remote mode doesn't keep syncing + // the abandoned local ledger. + disconnect: () => { + const config = getConfig(); + if (!config.desktop) redirect(303, "/settings"); + disarmLocalMode(); + clearDesktopConfig(config.dbPath); + redirect(303, "/setup"); + }, }; diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 09952db..f1e69d8 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -9,6 +9,32 @@ const dateFmt = new Intl.DateTimeFormat(undefined, { }); let syncing = $state(false); let renamingId = $state(null); + +// Desktop-shell detection for the Disconnect section. Server-rendered flag in +// the desktop build's own pages (local mode); client-side detection of the +// webview's injected `bindings` proxy when these pages are served by a hosted +// server viewed through the desktop app (remote mode). +let inDesktopShell = $state(false); +let confirmingDisconnect = $state(false); +let disconnectError = $state(""); +$effect(() => { + // deno-lint-ignore no-explicit-any + inDesktopShell = typeof (globalThis as any).bindings !== "undefined"; +}); + +async function remoteDisconnect() { + disconnectError = ""; + try { + // The shell clears its mode choice and navigates back to its setup + // screen; nothing on this server changes. + // deno-lint-ignore no-explicit-any + await (globalThis as any).bindings.quantumDisconnect(); + } catch { + disconnectError = + "This version of the desktop app can't disconnect from here yet. " + + "Quit the app and delete quantum-desktop.json from its data folder instead."; + } +}

Settings

@@ -215,6 +241,59 @@ let renamingId = $state(null); +{#if data.desktopShell || inDesktopShell} +
+

Disconnect

+ {#if data.desktopShell} +

+ Return this app to its setup screen. Nothing is deleted — your ledger + stays on this machine, and choosing “Just me” again picks it right + back up. +

+ {:else} +

+ You're viewing this server through the Quantum desktop app. + Disconnecting returns the app to its setup screen; nothing on the + server changes. +

+ {/if} + + {#if !confirmingDisconnect} +
+ +
+ {:else} +
+ {#if data.desktopShell} +
+ +
+ {:else} + + {/if} + +
+ {/if} + {#if disconnectError} + + {/if} +
+{/if} +