diff --git a/openspec/changes/add-desktop-local-remote-modes/design.md b/openspec/changes/add-desktop-local-remote-modes/design.md index c553858..9968057 100644 --- a/openspec/changes/add-desktop-local-remote-modes/design.md +++ b/openspec/changes/add-desktop-local-remote-modes/design.md @@ -306,6 +306,28 @@ chain of discoveries, each committed as a fix: backend (WebView2/laufey) on this machine. `--backend cef` (bundled Chromium, ~300MB binary vs ~68MB) renders reliably. `desktop` uses CEF by default; `desktop:webview` keeps the smaller variant for machines where it works. +- **The webview leaves localhost freely — remote mode needs no Deno API.** A + packaged CEF spike confirmed the webview navigates to external https origins + by a page's own `location.href` redirect, and that `Deno.BrowserWindow` + exists (the first construction adopts the startup window) with a working + `navigate()` and cross-origin `executeJs()`. Remote mode is therefore just a + hook-level 303: `handleLocal` redirects every request to the configured + origin and the webview lives there from then on. No window API in the app. +- **The CEF profile is ephemeral: remote mode re-logs-in each launch.** The + spike's Max-Age cookie did not survive a relaunch; no profile directory is + written, the docs expose no cache-path knob, and Chromium switches passed to + the exe are not forwarded. Within a session cookies behave normally, so the + login flow works — it just runs again on each launch until `deno desktop` + exposes a persistent profile (tracked as an open question). Decision 5's + architecture is unaffected; this is a UX cost, stated rather than papered + over. +- **The database is created only when local mode is chosen.** `init` used to + open `quantum.db` unconditionally, which would have left a stray database on + every remote install. Local machinery (database, identity seed, sync timers, + loopback MCP) now arms exactly once via `armLocalMode` — from `init` when + local mode is already persisted, or lazily from the hook the moment the user + picks Local on `/setup`, which also removes the old implicit relaunch + dependency. First launch and remote mode touch no database at all. - **First-launch is app-config-driven, not baked.** A shipped binary can't have a mode or display name compiled in. `QUANTUM_DESKTOP=1` makes the app read `quantum-desktop.json` (beside the database, outside it) at runtime; when @@ -330,5 +352,10 @@ 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`. +- **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 + or cache-path option; when one lands, remote-mode sessions become durable + with no change to our code. - **Auto-update and artifact signing.** Out of scope here, but the release story will need it before wide distribution. diff --git a/openspec/changes/add-desktop-local-remote-modes/tasks.md b/openspec/changes/add-desktop-local-remote-modes/tasks.md index 376825a..8f54982 100644 --- a/openspec/changes/add-desktop-local-remote-modes/tasks.md +++ b/openspec/changes/add-desktop-local-remote-modes/tasks.md @@ -159,13 +159,30 @@ webview pointed at the server, logging in via the server's normal cookie flow. No URL scheme, no deep link, no handoff endpoint, no bearer-session issuance — the former tasks 6.1/6.3/6.4/6.5 are dropped. (See design decision 5.) -- [ ] 6.1 Remote-mode shell: point the webview at the user-provided server +- [x] 6.1 Remote-mode shell: point the webview at the user-provided server origin; start no embedded server and open no local database. Confirm the server's cookie login completes inside the webview and the session cookie - persists (needs the desktop binary). -- [ ] 6.2 Server side: nothing. Verify `src/routes/login` and + persists (needs the desktop binary). — Implemented as a hook-level gate: + in desktop remote mode `handleLocal` 303s every request (including + `/mcp`) to the configured origin, before anything touches the database. + The embedded server can't be *not started* (`deno desktop` owns it), but + it serves nothing and opens nothing. Database creation is now deferred + until local mode is actually chosen (`armLocalMode`, called lazily), so + first launch and remote mode never create `quantum.db` — verified live + (data dir holds only `quantum-desktop.json`). A packaged-binary spike + proved the CEF webview navigates to external https origins (both a page's + own `location.href` redirect and `Deno.BrowserWindow.navigate()`), so the + 303 hand-off works in the shipped app. **Cookie finding:** the CEF + profile is ephemeral — even Max-Age cookies do not survive a relaunch, + and no `deno desktop` knob or pass-through Chromium switch persists it — + so remote mode means logging in again on each launch until upstream + exposes a persistent profile. In-session cookies behave normally. The + in-webview login E2E against a real server is 8.3. +- [x] 6.2 Server side: nothing. Verify `src/routes/login` and `src/routes/oauth/callback` are used unchanged — no handoff route, no - session-delivery marker, no schema change. + session-delivery marker, no schema change. — Confirmed: the remote-mode + diff touches only `hooks.server.ts` and `src/routes/setup`; login and + callback routes and the schema are untouched. ## 7. First-launch experience @@ -177,12 +194,18 @@ the former tasks 6.1/6.3/6.4/6.5 are dropped. (See design decision 5.) the full flow live and in the CEF window. - [x] 7.2 Local branch: prompt for a display name and seed the local user with it. — `src/routes/welcome` (built earlier); reached after the mode screen. -- [ ] 7.3 Remote branch: collect the server address and point the webview at it - (task 6); the server's cookie login takes over from there. — DEFERRED. The - option is present on `/setup` but disabled ("soon"). Remote-in-desktop is - architecturally uncertain: `deno desktop` always runs the embedded server - and points the webview at localhost, so navigating the webview to an - external origin needs its own spike. Scoped out of this pass by decision. +- [x] 7.3 Remote branch: collect the server address and point the webview at it + (task 6); the server's cookie login takes over from there. — Un-deferred: + the navigation spike (see 6.1) resolved the architectural uncertainty, so + the "soon" placeholder is now a working option. Selecting it expands an + inline form; the action normalizes the address (scheme defaults to + https), probes `GET /healthz` for `{ ok: true }` with a 5s timeout so a + typo or non-Quantum host fails with a message instead of stranding the + webview, persists `{ mode: "remote", serverUrl }`, and 303s the webview + to the origin. Verified live: bad-address and non-Quantum-host errors + 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 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). @@ -202,9 +225,17 @@ the former tasks 6.1/6.3/6.4/6.5 are dropped. (See design decision 5.) agent-provenance tail still wants a real bank connection exercised in the packaged app; the pieces are individually tested (sync, MCP, provenance) but not yet driven as one desktop session. -- [ ] 8.3 Remote mode end to end against a dev server: choose Remote → the +- [x] 8.3 Remote mode end to end against a dev server: choose Remote → the server's cookie login runs in the webview → app authenticates as the - logged-in user via cookie → no local database created. + logged-in user via cookie → no local database created. — Driven by Graham + against his real hosted instance in the rebuilt packaged binary + (2026-07-17): Remote selected on `/setup`, the server's ATProto cookie + login completed inside the webview, and the app ran as the logged-in + user. No-local-database is verified at the hook level (remote mode never + calls `initDb`; the data dir holds only `quantum-desktop.json`). As + 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. - [x] 8.5 Confirm a hosted server rejects `QUANTUM_MODE=local` combined with diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 550f5cf..366d346 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -38,20 +38,22 @@ function logSyncOutcomes(outcomes: Awaited>): void { export const init: ServerInit = async () => { if (building) return; const config = getConfig(); // fails fast with a clear error on missing/invalid env - const db = initDb(config.dbPath); // migrations embedded in the bundle if (config.mode === "local") { - // Single-user desktop build: no login, no OAuth client. Seed the local - // identity when the display name is already chosen; otherwise the welcome - // flow seeds it on first launch. - if (config.localDisplayName) { - upsertUser(db, LOCAL_DID, config.localDisplayName); + if (config.desktop) { + // Desktop: the database and local machinery exist only once the user + // has chosen local mode. First launch (mode not yet chosen) and remote + // mode (thin client to a server) never create quantum.db; if local is + // chosen mid-session on /setup, handleLocal arms this lazily. + const chosen = readDesktopConfig(config.dbPath); + if (chosen?.mode === "local") armLocalMode(config); + } else { + armLocalMode(config); } - startLocalSync(); - startLoopbackMcp(config.dbPath); return; } + const db = initDb(config.dbPath); // migrations embedded in the bundle deleteExpiredSessions(db); await initOAuthClient(config, db); @@ -71,6 +73,26 @@ export const init: ServerInit = async () => { // 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()) @@ -188,8 +210,36 @@ async function handleLocal( event: RequestEvent, resolve: Resolve, ): Promise { - const db = getDb(); const path = event.url.pathname; + const config = getConfig(); + + // Desktop: the mode (local/remote) is chosen on a setup screen at first + // launch and persisted in app-config, not baked into the build. This gate + // runs before anything touches the database, because in remote mode (and + // before a choice is made) no local database exists. + if (config.desktop) { + const chosen = readDesktopConfig(config.dbPath); + if (chosen?.mode === "remote" && chosen.serverUrl) { + // Thin client: hand the webview to the server for every request. The + // server's own cookie login runs inside the webview (design decision + // 5); nothing — including /mcp — is served locally. + redirect(303, chosen.serverUrl); + } + if (!chosen || chosen.mode !== "local") { + event.locals.user = null; + event.locals.apiToken = null; + if (path !== "/setup" && !PUBLIC_PATHS.has(path)) { + redirect(303, "/setup"); + } + return resolve(event); + } + // Local mode chosen — possibly seconds ago on /setup: arm the database, + // sync, and loopback MCP now rather than requiring a relaunch. + armLocalMode(config); + if (path === "/setup") redirect(303, "/"); // already chosen + } + + const db = getDb(); // MCP is token-authenticated, never the local session. if (isMcpPath(path)) { @@ -204,21 +254,6 @@ async function handleLocal( event.locals.apiToken = null; - // Desktop first launch: the mode (local/remote) is chosen on a setup screen - // and persisted in app-config, not baked into the build. Until local mode is - // chosen, route everything to /setup. - if (getConfig().desktop) { - const chosen = readDesktopConfig(getConfig().dbPath); - if (!chosen || chosen.mode !== "local") { - event.locals.user = null; - if (path !== "/setup" && !PUBLIC_PATHS.has(path)) { - redirect(303, "/setup"); - } - return resolve(event); - } - if (path === "/setup") redirect(303, "/"); // already chosen - } - const localUser = getUser(db, LOCAL_DID); // Local mode chosen but the display name not yet set: go to /welcome. diff --git a/src/routes/setup/+page.server.ts b/src/routes/setup/+page.server.ts index aa7766f..0ad433f 100644 --- a/src/routes/setup/+page.server.ts +++ b/src/routes/setup/+page.server.ts @@ -1,4 +1,4 @@ -import { redirect } from "@sveltejs/kit"; +import { fail, redirect } from "@sveltejs/kit"; import { getConfig } from "$lib/server/config"; import { writeDesktopConfig } from "$lib/server/desktop-config"; import type { Actions, PageServerLoad } from "./$types"; @@ -18,4 +18,48 @@ export const actions: Actions = { // The hook now routes to /welcome to choose a display name. redirect(303, "/"); }, + + remote: async ({ request }) => { + const config = getConfig(); + if (!config.desktop) redirect(303, "/"); + + const form = await request.formData(); + const raw = String(form.get("serverUrl") ?? "").trim(); + if (!raw) { + return fail(400, { serverUrl: raw, error: "Enter your server's address." }); + } + + let origin: string; + try { + origin = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`) + .origin; + } catch { + return fail(400, { + serverUrl: raw, + error: "That doesn't look like a valid address.", + }); + } + + // Probe the server's health endpoint before committing, so a typo fails + // here with a message instead of stranding the webview on a dead origin. + // The body check ({ ok: true }) keeps an arbitrary website's 200 from + // passing as a Quantum server. + try { + const res = await fetch(`${origin}/healthz`, { + signal: AbortSignal.timeout(5000), + }); + const body = res.ok ? await res.json() : null; + if (body?.ok !== true) throw new Error("not a Quantum server"); + } catch { + return fail(400, { + serverUrl: raw, + error: + `Couldn't find a Quantum server at ${origin}. Check the address and that the server is running.`, + }); + } + + writeDesktopConfig(config.dbPath, { mode: "remote", serverUrl: origin }); + // Hand the webview to the server; its cookie login takes over from here. + redirect(303, origin); + }, }; diff --git a/src/routes/setup/+page.svelte b/src/routes/setup/+page.svelte index add38c5..2c92b72 100644 --- a/src/routes/setup/+page.svelte +++ b/src/routes/setup/+page.svelte @@ -1,7 +1,11 @@
@@ -29,14 +33,52 @@ let choosing = $state(false); -
- I have a Quantum server soon - - Connecting this app to a self-hosted Quantum server is coming. For now, - open your server in a web browser. - -
+ {#if !remoteOpen} + + {:else} +
{ + connecting = true; + return async ({ update }) => { + await update(); + connecting = false; + }; + }} + > + I have a Quantum server + + + {#if form?.error} +

{form.error}

+ {/if} + +
+ {/if}
@@ -92,8 +134,39 @@ button.option:hover:not(:disabled) { background: var(--bg-sunken); } -.option.disabled { - opacity: 0.6; +.remote-form { + border-color: var(--accent); +} + +.remote-form input { + font: inherit; + font-family: var(--font-mono); + padding: var(--space-2); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg-sunken); + color: inherit; +} + +.remote-form .connect { + align-self: flex-start; + font: inherit; + font-weight: 600; + padding: var(--space-2) var(--space-4); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg-raised); + cursor: pointer; +} + +.remote-form .connect:hover:not(:disabled) { + border-color: var(--accent); +} + +.error { + color: var(--neg); + font-size: var(--text-sm); + margin: 0; } .option-title { @@ -106,14 +179,4 @@ button.option:hover:not(:disabled) { font-size: var(--text-sm); } -.soon { - font-size: var(--text-xs); - font-weight: 500; - color: var(--text-faint); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 0 var(--space-1); - margin-left: var(--space-1); - vertical-align: middle; -}