A self-hosted household ledger.
0

Configure Feed

Select the types of activity you want to include in your feed.

add desktop remote mode: setup server-address branch, healthz probe, webview handoff to server origin

author graham.systems date (Jul 23, 2026, 11:19 AM -0700) commit 0839c7fa parent 89912e82 change-id nuouwmrn
+257 -57
+27
openspec/changes/add-desktop-local-remote-modes/design.md
··· 306 306 backend (WebView2/laufey) on this machine. `--backend cef` (bundled Chromium, 307 307 ~300MB binary vs ~68MB) renders reliably. `desktop` uses CEF by default; 308 308 `desktop:webview` keeps the smaller variant for machines where it works. 309 + - **The webview leaves localhost freely — remote mode needs no Deno API.** A 310 + packaged CEF spike confirmed the webview navigates to external https origins 311 + by a page's own `location.href` redirect, and that `Deno.BrowserWindow` 312 + exists (the first construction adopts the startup window) with a working 313 + `navigate()` and cross-origin `executeJs()`. Remote mode is therefore just a 314 + hook-level 303: `handleLocal` redirects every request to the configured 315 + origin and the webview lives there from then on. No window API in the app. 316 + - **The CEF profile is ephemeral: remote mode re-logs-in each launch.** The 317 + spike's Max-Age cookie did not survive a relaunch; no profile directory is 318 + written, the docs expose no cache-path knob, and Chromium switches passed to 319 + the exe are not forwarded. Within a session cookies behave normally, so the 320 + login flow works — it just runs again on each launch until `deno desktop` 321 + exposes a persistent profile (tracked as an open question). Decision 5's 322 + architecture is unaffected; this is a UX cost, stated rather than papered 323 + over. 324 + - **The database is created only when local mode is chosen.** `init` used to 325 + open `quantum.db` unconditionally, which would have left a stray database on 326 + every remote install. Local machinery (database, identity seed, sync timers, 327 + loopback MCP) now arms exactly once via `armLocalMode` — from `init` when 328 + local mode is already persisted, or lazily from the hook the moment the user 329 + picks Local on `/setup`, which also removes the old implicit relaunch 330 + dependency. First launch and remote mode touch no database at all. 309 331 - **First-launch is app-config-driven, not baked.** A shipped binary can't have 310 332 a mode or display name compiled in. `QUANTUM_DESKTOP=1` makes the app read 311 333 `quantum-desktop.json` (beside the database, outside it) at runtime; when ··· 330 352 binary?** (decision 4) The main server's port is auto-selected per launch, so 331 353 a stable MCP URL for a statically-configured agent host depends on this. If 332 354 not, fall back to publishing the per-launch URL via `agent.json`. 355 + - **CEF profile persistence (upstream).** The webview's cookie jar does not 356 + survive relaunch (see Resolved During Implementation), so remote mode 357 + re-authenticates every launch. Watch `deno desktop` for a persistent-profile 358 + or cache-path option; when one lands, remote-mode sessions become durable 359 + with no change to our code. 333 360 - **Auto-update and artifact signing.** Out of scope here, but the release story 334 361 will need it before wide distribution.
+43 -12
openspec/changes/add-desktop-local-remote-modes/tasks.md
··· 159 159 No URL scheme, no deep link, no handoff endpoint, no bearer-session issuance — 160 160 the former tasks 6.1/6.3/6.4/6.5 are dropped. (See design decision 5.) 161 161 162 - - [ ] 6.1 Remote-mode shell: point the webview at the user-provided server 162 + - [x] 6.1 Remote-mode shell: point the webview at the user-provided server 163 163 origin; start no embedded server and open no local database. Confirm the 164 164 server's cookie login completes inside the webview and the session cookie 165 - persists (needs the desktop binary). 166 - - [ ] 6.2 Server side: nothing. Verify `src/routes/login` and 165 + persists (needs the desktop binary). — Implemented as a hook-level gate: 166 + in desktop remote mode `handleLocal` 303s every request (including 167 + `/mcp`) to the configured origin, before anything touches the database. 168 + The embedded server can't be *not started* (`deno desktop` owns it), but 169 + it serves nothing and opens nothing. Database creation is now deferred 170 + until local mode is actually chosen (`armLocalMode`, called lazily), so 171 + first launch and remote mode never create `quantum.db` — verified live 172 + (data dir holds only `quantum-desktop.json`). A packaged-binary spike 173 + proved the CEF webview navigates to external https origins (both a page's 174 + own `location.href` redirect and `Deno.BrowserWindow.navigate()`), so the 175 + 303 hand-off works in the shipped app. **Cookie finding:** the CEF 176 + profile is ephemeral — even Max-Age cookies do not survive a relaunch, 177 + and no `deno desktop` knob or pass-through Chromium switch persists it — 178 + so remote mode means logging in again on each launch until upstream 179 + exposes a persistent profile. In-session cookies behave normally. The 180 + in-webview login E2E against a real server is 8.3. 181 + - [x] 6.2 Server side: nothing. Verify `src/routes/login` and 167 182 `src/routes/oauth/callback` are used unchanged — no handoff route, no 168 - session-delivery marker, no schema change. 183 + session-delivery marker, no schema change. — Confirmed: the remote-mode 184 + diff touches only `hooks.server.ts` and `src/routes/setup`; login and 185 + callback routes and the schema are untouched. 169 186 170 187 ## 7. First-launch experience 171 188 ··· 177 194 the full flow live and in the CEF window. 178 195 - [x] 7.2 Local branch: prompt for a display name and seed the local user with 179 196 it. — `src/routes/welcome` (built earlier); reached after the mode screen. 180 - - [ ] 7.3 Remote branch: collect the server address and point the webview at it 181 - (task 6); the server's cookie login takes over from there. — DEFERRED. The 182 - option is present on `/setup` but disabled ("soon"). Remote-in-desktop is 183 - architecturally uncertain: `deno desktop` always runs the embedded server 184 - and points the webview at localhost, so navigating the webview to an 185 - external origin needs its own spike. Scoped out of this pass by decision. 197 + - [x] 7.3 Remote branch: collect the server address and point the webview at it 198 + (task 6); the server's cookie login takes over from there. — Un-deferred: 199 + the navigation spike (see 6.1) resolved the architectural uncertainty, so 200 + the "soon" placeholder is now a working option. Selecting it expands an 201 + inline form; the action normalizes the address (scheme defaults to 202 + https), probes `GET /healthz` for `{ ok: true }` with a 5s timeout so a 203 + typo or non-Quantum host fails with a message instead of stranding the 204 + webview, persists `{ mode: "remote", serverUrl }`, and 303s the webview 205 + to the origin. Verified live: bad-address and non-Quantum-host errors 206 + render inline with the input preserved; a valid address lands the 207 + browser on the remote origin; every subsequent request to the embedded 208 + server redirects there. 186 209 - [ ] 7.4 Add the reset action (Settings) that clears app-config and returns to 187 210 the mode-selection screen on next launch. Confirm with a plain sentence 188 211 about what reset does and does not delete (local data stays on disk). ··· 202 225 agent-provenance tail still wants a real bank connection exercised in the 203 226 packaged app; the pieces are individually tested (sync, MCP, provenance) but 204 227 not yet driven as one desktop session. 205 - - [ ] 8.3 Remote mode end to end against a dev server: choose Remote → the 228 + - [x] 8.3 Remote mode end to end against a dev server: choose Remote → the 206 229 server's cookie login runs in the webview → app authenticates as the 207 - logged-in user via cookie → no local database created. 230 + logged-in user via cookie → no local database created. — Driven by Graham 231 + against his real hosted instance in the rebuilt packaged binary 232 + (2026-07-17): Remote selected on `/setup`, the server's ATProto cookie 233 + login completed inside the webview, and the app ran as the logged-in 234 + user. No-local-database is verified at the hook level (remote mode never 235 + calls `initDb`; the data dir holds only `quantum-desktop.json`). As 236 + predicted by the profile spike, the session did not survive an app 237 + relaunch (ephemeral CEF profile — see design's open question); login runs 238 + again each launch. 208 239 - [ ] 8.4 Reset from each mode returns to the selection screen; local data files 209 240 remain on disk after a reset. 210 241 - [x] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with
+59 -24
src/hooks.server.ts
··· 38 38 export const init: ServerInit = async () => { 39 39 if (building) return; 40 40 const config = getConfig(); // fails fast with a clear error on missing/invalid env 41 - const db = initDb(config.dbPath); // migrations embedded in the bundle 42 41 43 42 if (config.mode === "local") { 44 - // Single-user desktop build: no login, no OAuth client. Seed the local 45 - // identity when the display name is already chosen; otherwise the welcome 46 - // flow seeds it on first launch. 47 - if (config.localDisplayName) { 48 - upsertUser(db, LOCAL_DID, config.localDisplayName); 43 + if (config.desktop) { 44 + // Desktop: the database and local machinery exist only once the user 45 + // has chosen local mode. First launch (mode not yet chosen) and remote 46 + // mode (thin client to a server) never create quantum.db; if local is 47 + // chosen mid-session on /setup, handleLocal arms this lazily. 48 + const chosen = readDesktopConfig(config.dbPath); 49 + if (chosen?.mode === "local") armLocalMode(config); 50 + } else { 51 + armLocalMode(config); 49 52 } 50 - startLocalSync(); 51 - startLoopbackMcp(config.dbPath); 52 53 return; 53 54 } 54 55 56 + const db = initDb(config.dbPath); // migrations embedded in the bundle 55 57 deleteExpiredSessions(db); 56 58 await initOAuthClient(config, db); 57 59 ··· 70 72 // day the app wasn't open. Instead: sync once on launch (catching up whatever 71 73 // was missed while closed) and periodically while the app runs. 72 74 const LOCAL_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours 75 + 76 + let localArmed = false; 77 + 78 + /** 79 + * Bring up local-mode machinery exactly once: open (and create) the database, 80 + * seed the local identity, start the catch-up/interval sync and the optional 81 + * loopback MCP listener. Called from `init` when local mode is already chosen, 82 + * or from `handleLocal` the moment the user chooses it on /setup — so the 83 + * choice takes effect without a relaunch, and remote mode never runs any of it. 84 + */ 85 + function armLocalMode(config: ReturnType<typeof getConfig>): void { 86 + if (localArmed) return; 87 + localArmed = true; 88 + const db = initDb(config.dbPath); 89 + if (config.localDisplayName) { 90 + upsertUser(db, LOCAL_DID, config.localDisplayName); 91 + } 92 + startLocalSync(); 93 + startLoopbackMcp(config.dbPath); 94 + } 73 95 74 96 function startLocalSync(): void { 75 97 const run = () => ··· 188 210 event: RequestEvent, 189 211 resolve: Resolve, 190 212 ): Promise<Response> { 213 + const path = event.url.pathname; 214 + const config = getConfig(); 215 + 216 + // Desktop: the mode (local/remote) is chosen on a setup screen at first 217 + // launch and persisted in app-config, not baked into the build. This gate 218 + // runs before anything touches the database, because in remote mode (and 219 + // before a choice is made) no local database exists. 220 + if (config.desktop) { 221 + const chosen = readDesktopConfig(config.dbPath); 222 + if (chosen?.mode === "remote" && chosen.serverUrl) { 223 + // Thin client: hand the webview to the server for every request. The 224 + // server's own cookie login runs inside the webview (design decision 225 + // 5); nothing — including /mcp — is served locally. 226 + redirect(303, chosen.serverUrl); 227 + } 228 + if (!chosen || chosen.mode !== "local") { 229 + event.locals.user = null; 230 + event.locals.apiToken = null; 231 + if (path !== "/setup" && !PUBLIC_PATHS.has(path)) { 232 + redirect(303, "/setup"); 233 + } 234 + return resolve(event); 235 + } 236 + // Local mode chosen — possibly seconds ago on /setup: arm the database, 237 + // sync, and loopback MCP now rather than requiring a relaunch. 238 + armLocalMode(config); 239 + if (path === "/setup") redirect(303, "/"); // already chosen 240 + } 241 + 191 242 const db = getDb(); 192 - const path = event.url.pathname; 193 243 194 244 // MCP is token-authenticated, never the local session. 195 245 if (isMcpPath(path)) { ··· 203 253 } 204 254 205 255 event.locals.apiToken = null; 206 - 207 - // Desktop first launch: the mode (local/remote) is chosen on a setup screen 208 - // and persisted in app-config, not baked into the build. Until local mode is 209 - // chosen, route everything to /setup. 210 - if (getConfig().desktop) { 211 - const chosen = readDesktopConfig(getConfig().dbPath); 212 - if (!chosen || chosen.mode !== "local") { 213 - event.locals.user = null; 214 - if (path !== "/setup" && !PUBLIC_PATHS.has(path)) { 215 - redirect(303, "/setup"); 216 - } 217 - return resolve(event); 218 - } 219 - if (path === "/setup") redirect(303, "/"); // already chosen 220 - } 221 256 222 257 const localUser = getUser(db, LOCAL_DID); 223 258
+45 -1
src/routes/setup/+page.server.ts
··· 1 - import { redirect } from "@sveltejs/kit"; 1 + import { fail, redirect } from "@sveltejs/kit"; 2 2 import { getConfig } from "$lib/server/config"; 3 3 import { writeDesktopConfig } from "$lib/server/desktop-config"; 4 4 import type { Actions, PageServerLoad } from "./$types"; ··· 17 17 writeDesktopConfig(config.dbPath, { mode: "local" }); 18 18 // The hook now routes to /welcome to choose a display name. 19 19 redirect(303, "/"); 20 + }, 21 + 22 + remote: async ({ request }) => { 23 + const config = getConfig(); 24 + if (!config.desktop) redirect(303, "/"); 25 + 26 + const form = await request.formData(); 27 + const raw = String(form.get("serverUrl") ?? "").trim(); 28 + if (!raw) { 29 + return fail(400, { serverUrl: raw, error: "Enter your server's address." }); 30 + } 31 + 32 + let origin: string; 33 + try { 34 + origin = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`) 35 + .origin; 36 + } catch { 37 + return fail(400, { 38 + serverUrl: raw, 39 + error: "That doesn't look like a valid address.", 40 + }); 41 + } 42 + 43 + // Probe the server's health endpoint before committing, so a typo fails 44 + // here with a message instead of stranding the webview on a dead origin. 45 + // The body check ({ ok: true }) keeps an arbitrary website's 200 from 46 + // passing as a Quantum server. 47 + try { 48 + const res = await fetch(`${origin}/healthz`, { 49 + signal: AbortSignal.timeout(5000), 50 + }); 51 + const body = res.ok ? await res.json() : null; 52 + if (body?.ok !== true) throw new Error("not a Quantum server"); 53 + } catch { 54 + return fail(400, { 55 + serverUrl: raw, 56 + error: 57 + `Couldn't find a Quantum server at ${origin}. Check the address and that the server is running.`, 58 + }); 59 + } 60 + 61 + writeDesktopConfig(config.dbPath, { mode: "remote", serverUrl: origin }); 62 + // Hand the webview to the server; its cookie login takes over from here. 63 + redirect(303, origin); 20 64 }, 21 65 };
+83 -20
src/routes/setup/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { enhance } from "$app/forms"; 3 3 4 + let { form } = $props(); 5 + 4 6 let choosing = $state(false); 7 + let remoteOpen = $state(false); 8 + let connecting = $state(false); 5 9 </script> 6 10 7 11 <main> ··· 29 33 </button> 30 34 </form> 31 35 32 - <div class="option disabled" aria-disabled="true"> 33 - <span 34 - class="option-title">I have a Quantum server <span class="soon">soon</span></span> 35 - <span class="option-desc"> 36 - Connecting this app to a self-hosted Quantum server is coming. For now, 37 - open your server in a web browser. 38 - </span> 39 - </div> 36 + {#if !remoteOpen} 37 + <button 38 + class="option" 39 + type="button" 40 + disabled={choosing} 41 + onclick={() => (remoteOpen = true)} 42 + > 43 + <span class="option-title">I have a Quantum server</span> 44 + <span class="option-desc"> 45 + Use this app as a window onto your self-hosted server. You'll sign in 46 + with your usual account; nothing is stored on this machine. 47 + </span> 48 + </button> 49 + {:else} 50 + <form 51 + class="option remote-form" 52 + method="POST" 53 + action="?/remote" 54 + use:enhance={() => { 55 + connecting = true; 56 + return async ({ update }) => { 57 + await update(); 58 + connecting = false; 59 + }; 60 + }} 61 + > 62 + <span class="option-title">I have a Quantum server</span> 63 + <label class="option-desc" for="serverUrl">Server address</label> 64 + <input 65 + id="serverUrl" 66 + name="serverUrl" 67 + type="text" 68 + placeholder="quantum.example.com" 69 + value={form?.serverUrl ?? ""} 70 + disabled={connecting} 71 + autocomplete="off" 72 + spellcheck="false" 73 + /> 74 + {#if form?.error} 75 + <p class="error">{form.error}</p> 76 + {/if} 77 + <button class="connect" type="submit" disabled={connecting}> 78 + {connecting ? "Checking…" : "Connect"} 79 + </button> 80 + </form> 81 + {/if} 40 82 </div> 41 83 </main> 42 84 ··· 92 134 background: var(--bg-sunken); 93 135 } 94 136 95 - .option.disabled { 96 - opacity: 0.6; 137 + .remote-form { 138 + border-color: var(--accent); 139 + } 140 + 141 + .remote-form input { 142 + font: inherit; 143 + font-family: var(--font-mono); 144 + padding: var(--space-2); 145 + border: 1px solid var(--border-strong); 146 + border-radius: var(--radius-sm); 147 + background: var(--bg-sunken); 148 + color: inherit; 149 + } 150 + 151 + .remote-form .connect { 152 + align-self: flex-start; 153 + font: inherit; 154 + font-weight: 600; 155 + padding: var(--space-2) var(--space-4); 156 + border: 1px solid var(--border-strong); 157 + border-radius: var(--radius-sm); 158 + background: var(--bg-raised); 159 + cursor: pointer; 160 + } 161 + 162 + .remote-form .connect:hover:not(:disabled) { 163 + border-color: var(--accent); 164 + } 165 + 166 + .error { 167 + color: var(--neg); 168 + font-size: var(--text-sm); 169 + margin: 0; 97 170 } 98 171 99 172 .option-title { ··· 106 179 font-size: var(--text-sm); 107 180 } 108 181 109 - .soon { 110 - font-size: var(--text-xs); 111 - font-weight: 500; 112 - color: var(--text-faint); 113 - border: 1px solid var(--border); 114 - border-radius: var(--radius-sm); 115 - padding: 0 var(--space-1); 116 - margin-left: var(--space-1); 117 - vertical-align: middle; 118 - } 119 182 </style>