From 17112419880031e687580ff85629bec11e7663d2 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 5 Oct 2026 19:37:00 -0400 Subject: [PATCH 1/4] feat(ui): first-run setup screen and server connect form Adds bootSetup with the embedded and connect choices. It follows other windows' decisions via setup:decided plus shellState, keeps its listener across failed attempts, and aborts an idle connect form when a sibling decides. bootNativeClient gains a setup entry: editable URL, CA picker, no auto-probe, and the provider built from the connected URL. The transport adds pickCACert, chooseEmbedded, shellState and quitApp; shell modes gain setup and reopen. Refs RIG-4564 Co-authored-by: Matt Wilkinson --- apps/ui/src/boot-mode.test.ts | 32 +- apps/ui/src/boot-mode.ts | 42 ++- apps/ui/src/boot-native.test.ts | 198 +++++++++-- apps/ui/src/boot-native.ts | 258 +++++++++----- apps/ui/src/boot-setup.test.ts | 374 +++++++++++++++++++++ apps/ui/src/boot-setup.ts | 242 +++++++++++++ apps/ui/src/daemon-transport.ts | 140 +++++++- apps/ui/src/daemon-transport.wails.test.ts | 84 ++++- apps/ui/src/shell-globals.test.ts | 6 + apps/ui/src/shell-globals.ts | 2 +- 10 files changed, 1245 insertions(+), 133 deletions(-) create mode 100644 apps/ui/src/boot-setup.test.ts create mode 100644 apps/ui/src/boot-setup.ts diff --git a/apps/ui/src/boot-mode.test.ts b/apps/ui/src/boot-mode.test.ts index 7ebdbba9b..ac500b087 100644 --- a/apps/ui/src/boot-mode.test.ts +++ b/apps/ui/src/boot-mode.test.ts @@ -4,6 +4,7 @@ import { bootConnection } from "./boot"; import { bootBrowser } from "./boot-browser"; import { type BootModeDeps, bootForMode, defaultDeps } from "./boot-mode"; import { bootNativeClient } from "./boot-native"; +import { bootSetup } from "./boot-setup"; import type { ConnectionProvider, ResolvedConnection } from "./live/provider"; const CONNECTION: ResolvedConnection = { @@ -18,6 +19,7 @@ function provider(connection: ResolvedConnection): ConnectionProvider { describe("bootForMode", () => { let root: HTMLElement; + let setupCalls: number; let clientCalls: number; let embeddedFactoryCalls: number; let browserCalls: number; @@ -26,6 +28,7 @@ describe("bootForMode", () => { beforeEach(() => { root = document.createElement("div"); + setupCalls = 0; clientCalls = 0; embeddedFactoryCalls = 0; browserCalls = 0; @@ -36,6 +39,11 @@ describe("bootForMode", () => { clientCalls++; return CONNECTION; }, + bootSetup: async (receivedRoot) => { + expect(receivedRoot).toBe(root); + setupCalls++; + return CONNECTION; + }, embeddedConnectionProvider: () => { embeddedFactoryCalls++; return provider(CONNECTION); @@ -83,22 +91,38 @@ describe("bootForMode", () => { expect(clientCalls).toBe(0); expect(connectionBootCalls).toBe(0); }); + + test("setup routes to bootSetup", async () => { + const connection = await bootForMode("setup", root, deps)(); + + expect(connection).toBe(CONNECTION); + expect(setupCalls).toBe(1); + expect(clientCalls).toBe(0); + }); + + test("reopen renders the neutral screen and never boots a connection", async () => { + await bootForMode("reopen", root, deps)(); + + expect(root.textContent).toContain( + "Compass is already set up. Quit and reopen it to change this.", + ); + expect(clientCalls).toBe(0); + expect(browserCalls).toBe(0); + expect(connectionBootCalls).toBe(0); + }); }); describe("defaultDeps production wiring", () => { test("binds the real boot functions", () => { expect(defaultDeps.bootNativeClient).toBe(bootNativeClient); + expect(defaultDeps.bootSetup).toBe(bootSetup); expect(defaultDeps.bootConnection).toBe(bootConnection); expect(defaultDeps.bootBrowser).toBe(bootBrowser); }); test("embedded provider is the bridge provider (fetchImpl set, no bearer), NOT the env provider", async () => { const resolved = await defaultDeps.embeddedConnectionProvider().resolve(); - // DL-111 ambient-admin: no bearer crosses the IPC seam. expect(resolved.token).toBeUndefined(); - // The IPC tunnel fetch — defined for nativeConnectionProvider, undefined - // for envConnectionProvider; this is what discriminates the two, so a - // revert of embedded→env would fail here. expect(resolved.fetchImpl).toBeDefined(); }); }); diff --git a/apps/ui/src/boot-mode.ts b/apps/ui/src/boot-mode.ts index 8700300d2..59ac7d8fe 100644 --- a/apps/ui/src/boot-mode.ts +++ b/apps/ui/src/boot-mode.ts @@ -1,18 +1,18 @@ import { bootConnection } from "./boot"; import { bootBrowser } from "./boot-browser"; import { bootNativeClient } from "./boot-native"; -import { nativeConnectionProvider } from "./daemon-transport"; +import { bootSetup } from "./boot-setup"; +import { nativeConnectionProvider, quitApp } from "./daemon-transport"; import type { ConnectionProvider, ResolvedConnection } from "./live/provider"; import { type ShellMode, shellServerUrl } from "./shell-globals"; -/** The launch mode `bootForMode` dispatches on: the shell-injected `ShellMode`, - * or undefined in a browser dev build where no shell sets it. */ export type BootMode = ShellMode | undefined; export type BootModeDeps = { bootNativeClient: ( root: HTMLElement, ) => Promise; + bootSetup: (root: HTMLElement) => Promise; embeddedConnectionProvider: () => ConnectionProvider; bootBrowser: (root: HTMLElement) => Promise; bootConnection: ( @@ -23,17 +23,30 @@ export type BootModeDeps = { export const defaultDeps: BootModeDeps = { bootNativeClient, - // Embedded never receives __COMPASS_SERVER_URL__ (injected in client mode only), and the - // bridge fetch routes over Wails IPC by path — so this is a syntactic same-origin - // placeholder, never dialed. Must be ABSOLUTE: createDaemonFetch does `new Request(url)`, - // which rejects a relative URL. Matches the packages/compass-client convention. + bootSetup, embeddedConnectionProvider: () => nativeConnectionProvider(shellServerUrl() ?? "http://compass.localhost"), bootBrowser, bootConnection, }; -/** Select the runtime boot thunk for the shell-injected launch mode. */ +function showReopen(root: HTMLElement): void { + const screen = document.createElement("div"); + screen.setAttribute( + "style", + "margin:0;padding:2rem;font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e6e6e6;background:#1a1a1a;min-height:100vh", + ); + const message = document.createElement("p"); + message.textContent = + "Compass is already set up. Quit and reopen it to change this."; + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Quit"; + button.addEventListener("click", () => void quitApp()); + screen.append(message, button); + root.replaceChildren(screen); +} + export function bootForMode( mode: BootMode, root: HTMLElement, @@ -47,7 +60,18 @@ export function bootForMode( deps.bootConnection(root, () => deps.embeddedConnectionProvider().resolve(), ); - default: + case "setup": + return () => deps.bootSetup(root); + case "reopen": + return async () => { + showReopen(root); + return undefined; + }; + case undefined: return () => deps.bootBrowser(root); + default: { + const exhaustive: never = mode; + throw new Error(`Unhandled boot mode: ${exhaustive}`); + } } } diff --git a/apps/ui/src/boot-native.test.ts b/apps/ui/src/boot-native.test.ts index bab045347..4f9cd7666 100644 --- a/apps/ui/src/boot-native.test.ts +++ b/apps/ui/src/boot-native.test.ts @@ -1,36 +1,19 @@ /// import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { bootNativeClient, type NativeBootDeps } from "./boot-native"; -import type { ConnectResult } from "./daemon-transport"; +import type { ConnectResult, PickedCA, ServerChoice } from "./daemon-transport"; import type { ConnectionProvider, ResolvedConnection } from "./live/provider"; -// The client-mode boot gate (boot-native.ts) drives the shell `Connect` probe -// and, on failure, the connect screen. The seam it consumes — `shellConnect` -// (the probe) and `nativeConnectionProvider` (the resolved connection) — is a -// `NativeBootDeps` injected as bootNativeClient's second argument, so no -// @wailsio/runtime, no IPC, and no network is needed. Injection over a -// `mock.module("./daemon-transport", …)` is deliberate: Bun's mock.module is -// process-global and its restore does not reliably rebind a sibling suite's -// named imports, so a whole-module mock of `./daemon-transport` leaked into -// daemon-transport.wails.test.ts and made both suites' outcomes depend on file -// order. The DI seam is self-contained to this suite. -// What is defended: -// - the in-flight `connecting` state renders before the probe settles; -// - each failure kind renders its distinct heading/copy; -// - an ok probe resolves the native provider with the injected baseUrl and -// token === undefined (DL-109); -// - submit is disabled on empty input, so the empty-token sentinel is never a -// user action; -// - after a connect-button submit the token input is cleared and no binding -// retains it (the stub records every token it was handed). - -// The token(s) the stub was handed, in call order — the spy the retention -// assertion reads. A local capture array, never the production module's state. +// The native client boot gate consumes injected shell transport dependencies. +// Tests drive calls directly without Wails, IPC, or a live network. +// +// The transport records server choices and captures each pending shell call. + +// The transport records the optional first-run server choice per call. let connectTokens: string[]; -// A programmable queue of resolvers, one per shellConnect call; a test settles a -// probe by resolving the matching entry (holding it lets a probe stay in flight). +let serverChoices: Array; let pending: Array<(result: ConnectResult) => void>; -// The stub transport injected into bootNativeClient, rebuilt fresh per test. +let pickedCA: PickedCA; let deps: NativeBootDeps; function connectResult(over: Partial): ConnectResult { @@ -41,6 +24,7 @@ function connectResult(over: Partial): ConnectResult { accountId: "", serverVersion: "", apiVersion: "", + serverUrl: "https://compass.example:8443", ...over, }; } @@ -53,14 +37,21 @@ const NATIVE_CONNECTION: ResolvedConnection = { beforeEach(() => { connectTokens = []; + serverChoices = []; pending = []; + pickedCA = { ref: "", name: "" }; deps = { - shellConnect: (token: string): Promise => { + shellConnect: ( + token: string, + server?: ServerChoice, + ): Promise => { connectTokens.push(token); + serverChoices.push(server); const { promise, resolve } = Promise.withResolvers(); pending.push(resolve); return promise; }, + pickCACert: async (): Promise => pickedCA, nativeConnectionProvider: (baseUrl: string): ConnectionProvider => ({ async resolve(): Promise { return { ...NATIVE_CONNECTION, baseUrl }; @@ -247,3 +238,156 @@ describe("bootNativeClient — the boot gate", () => { expect(connection?.token).toBeUndefined(); }); }); +test("setup entry shows an editable URL without probing and sends an empty CA ref", async () => { + const root = document.createElement("div"); + const booted = bootNativeClient(root, deps, "setup"); + await flush(); + + expect(connectTokens).toEqual([]); + expect(root.textContent).toContain("Connect to a server"); + const fields = root.querySelectorAll("input"); + expect(fields.length).toBe(2); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) { + throw new Error("setup form inputs are missing"); + } + url.value = "https://new.example:9443"; + url.dispatchEvent(new Event("input")); + token.value = "first-token"; + token.dispatchEvent(new Event("input")); + const button = [...root.querySelectorAll("button")].find( + (candidate) => candidate.textContent === "Connect", + ); + if (!(button instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); + button.click(); + await flush(); + + expect(connectTokens).toEqual(["first-token"]); + expect(serverChoices).toEqual([ + { url: "https://new.example:9443", caRef: "" }, + ]); + settle( + 0, + connectResult({ + ok: true, + kind: "", + serverUrl: "https://new.example:9443", + }), + ); + expect((await booted)?.baseUrl).toBe("https://new.example:9443"); +}); + +test("a picked CA name is shown and Use system trust clears its ref", async () => { + pickedCA = { ref: "opaque-ca-ref", name: "private-root.pem" }; + const root = document.createElement("div"); + void bootNativeClient(root, deps, "setup"); + await flush(); + const buttons = root.querySelectorAll("button"); + const choose = buttons.item(0); + if (!(choose instanceof HTMLButtonElement)) + throw new Error("CA button is missing"); + choose.click(); + await flush(); + expect(root.textContent).toContain("private-root.pem"); + + const trustButton = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "Use system trust", + ); + if (!(trustButton instanceof HTMLButtonElement)) + throw new Error("trust button is missing"); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) + throw new Error("setup form inputs are missing"); + url.value = "https://new.example"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + const connect = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ); + if (!(connect instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); + connect.click(); + await flush(); + expect(serverChoices).toEqual([ + { url: "https://new.example", caRef: "opaque-ca-ref" }, + ]); + settle(0, connectResult({ kind: "bad-token" })); + await flush(); + trustButton.click(); + expect(root.textContent).toContain("System trust"); + url.value = "https://new.example"; + url.dispatchEvent(new Event("input")); + token.value = "token-again"; + token.dispatchEvent(new Event("input")); + connect.click(); + await flush(); + expect(serverChoices).toEqual([ + { url: "https://new.example", caRef: "opaque-ca-ref" }, + { url: "https://new.example", caRef: "" }, + ]); + settle(1, connectResult({ kind: "bad-token" })); + await flush(); +}); +test("invalid URL messages are rendered literally and a success uses serverUrl", async () => { + const root = document.createElement("div"); + const booted = bootNativeClient(root, deps, "setup"); + await flush(); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) { + throw new Error("setup form inputs are missing"); + } + url.value = "not-validated-in-ui"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + const connect = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ); + if (!(connect instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); + connect.click(); + await flush(); + settle(0, connectResult({ kind: "invalid-url", message: "bad URL" })); + await flush(); + expect(root.querySelector("b")).toBeNull(); + expect(root.textContent).toContain("bad URL"); + + token.value = "good-token"; + token.dispatchEvent(new Event("input")); + connect.click(); + await flush(); + settle( + 1, + connectResult({ + ok: true, + kind: "", + serverUrl: "https://server-returned.example", + }), + ); + expect((await booted)?.baseUrl).toBe("https://server-returned.example"); +}); + +test("an idle setup form resolves undefined and clears when its signal aborts", async () => { + const root = document.createElement("div"); + const controller = new AbortController(); + const booted = bootNativeClient(root, deps, "setup", controller.signal); + await flush(); + expect(root.textContent).toContain("Connect to a server"); + controller.abort(); + expect(await booted).toBeUndefined(); + expect(root.childElementCount).toBe(0); +}); diff --git a/apps/ui/src/boot-native.ts b/apps/ui/src/boot-native.ts index ddebc6c38..d6be31b96 100644 --- a/apps/ui/src/boot-native.ts +++ b/apps/ui/src/boot-native.ts @@ -1,7 +1,4 @@ -// The client-mode boot gate: the sibling of `bootConnection` for the native desktop -// shell. Client mode cannot dial until the shell ARMS a connection (bearer is shell-side -// only, DL-109), so boot fires one auto-connect probe, shows `connecting`, and branches: -// ok → resolve the native provider and hand off; any failure → connect screen, retry in place. +// Native client boot connects through the shell, which keeps the bearer on its side. import { BUTTON_STYLE, @@ -14,28 +11,28 @@ import { import { type ConnectResult, nativeConnectionProvider, + pickCACert, + type ServerChoice, shellConnect, } from "./daemon-transport"; import type { ConnectionProvider, ResolvedConnection } from "./live/provider"; import { shellServerUrl } from "./shell-globals"; -// The transport seam `bootNativeClient` consumes, injectable so a test drives stubs -// directly rather than replacing the whole `./daemon-transport` module (Bun's -// `mock.module` is process-global and leaks across files, making outcomes -// order-dependent). Production callers omit it and get the real transport. export type NativeBootDeps = { - shellConnect: (token: string) => Promise; + shellConnect: ( + token: string, + server?: ServerChoice, + ) => Promise; + pickCACert: () => Promise<{ ref: string; name: string }>; nativeConnectionProvider: (baseUrl: string) => ConnectionProvider; }; const defaultNativeBootDeps: NativeBootDeps = { shellConnect, + pickCACert, nativeConnectionProvider, }; -/** The per-failure-kind screen copy (design failure-state table). `heading` is - * the one-line theme; `hint` is the actionable follow-up. `other` shows the - * server's own safe message — never a silent fallthrough. */ function failureCopy(result: ConnectResult): { heading: string; hint: string } { switch (result.kind) { case "bad-url": @@ -58,55 +55,49 @@ function failureCopy(result: ConnectResult): { heading: string; hint: string } { heading: `App speaks compass.v1; server speaks ${result.apiVersion}`, hint: "The app and the server disagree on the API version — upgrade whichever is behind.", }; - default: - return { - heading: "Could not connect", - hint: result.message, - }; + case "invalid-url": + case "invalid-ca": + case "other": + return { heading: "Could not connect", hint: result.message }; + case "": + throw new Error("Successful connect results have no failure copy"); + default: { + const exhaustive: never = result.kind; + throw new Error(`Unhandled connect result kind: ${exhaustive}`); + } } } -/** Fire the auto-connect probe, then either resolve the native connection or - * keep the connect screen up until a user-driven `shellConnect(token)` wins. - * Returns undefined only if the user genuinely cannot proceed; in practice it - * resolves once a probe succeeds (the screen retries in place). */ +/** Start the configured probe or open the first-run connection form. */ export async function bootNativeClient( root: HTMLElement, deps: NativeBootDeps = defaultNativeBootDeps, + entry: "configured" | "setup" = "configured", + signal?: AbortSignal, ): Promise { - renderConnecting(root); + if (entry === "setup") return awaitUserSetupConnect(root, deps, signal); - // The single boot-internal auto-connect probe (the empty-token sentinel). - // A user can never fire this: the connect button is disabled on empty input. + renderConnecting(root); const probe = await deps.shellConnect(""); if (probe.ok) { return deps.nativeConnectionProvider(shellServerUrl() ?? "").resolve(); } - - // The probe failed: hand off to the connect screen, which owns #root and - // resolves only when a user-driven connect succeeds. return awaitUserConnect(root, probe, deps); } -/** Paint the in-flight `connecting` state into `root`. */ function renderConnecting(root: HTMLElement): void { const screen = document.createElement("div"); screen.setAttribute("style", SCREEN_STYLE); - const headingEl = document.createElement("h1"); - headingEl.setAttribute("style", HEADING_STYLE); - headingEl.textContent = "Connecting…"; - const urlEl = document.createElement("p"); - urlEl.setAttribute("style", URL_STYLE); - urlEl.textContent = shellServerUrl() ?? ""; - screen.append(headingEl, urlEl); + const heading = document.createElement("h1"); + heading.setAttribute("style", HEADING_STYLE); + heading.textContent = "Connecting…"; + const url = document.createElement("p"); + url.setAttribute("style", URL_STYLE); + url.textContent = shellServerUrl() ?? ""; + screen.append(heading, url); root.replaceChildren(screen); } -/** Render the connect screen for a failed probe and keep it up, driving - * `shellConnect(token)` from the token input on each submit, until a probe - * succeeds — then resolve the native connection. The token lives only in the - * input's live value for the duration of one call and is cleared after (no - * module-scope binding, DL-109). */ function awaitUserConnect( root: HTMLElement, initial: ConnectResult, @@ -115,74 +106,181 @@ function awaitUserConnect( return new Promise((resolve) => { const screen = document.createElement("div"); screen.setAttribute("style", SCREEN_STYLE); - - const headingEl = document.createElement("h1"); - headingEl.setAttribute("style", HEADING_STYLE); - screen.append(headingEl); - - const detailEl = document.createElement("p"); - detailEl.setAttribute("style", DETAIL_STYLE); - screen.append(detailEl); - - const urlEl = document.createElement("p"); - urlEl.setAttribute("style", URL_STYLE); - // Read-only: the server URL is fixed by the shell, not editable here. - urlEl.textContent = `Server: ${shellServerUrl() ?? ""}`; - screen.append(urlEl); - + const heading = document.createElement("h1"); + heading.setAttribute("style", HEADING_STYLE); + const detail = document.createElement("p"); + detail.setAttribute("style", DETAIL_STYLE); + const url = document.createElement("p"); + url.setAttribute("style", URL_STYLE); + url.textContent = `Server: ${shellServerUrl() ?? ""}`; const input = document.createElement("input"); input.setAttribute("style", INPUT_STYLE); input.type = "password"; input.placeholder = "Paste your token"; input.autocomplete = "off"; - screen.append(input); - const button = document.createElement("button"); button.setAttribute("style", BUTTON_STYLE); button.type = "button"; button.textContent = "Connect"; - screen.append(button); + screen.append(heading, detail, url, input, button); - // Submit is disabled on empty input, so the empty-token "use-the-stored-one" - // sentinel is only ever the boot-internal probe, never a user action. const syncDisabled = (): void => { button.disabled = input.value.length === 0; }; - input.addEventListener("input", syncDisabled); - - // Paint the failure kind the probe returned. const paint = (result: ConnectResult): void => { - const { heading, hint } = failureCopy(result); - headingEl.textContent = heading; - detailEl.textContent = hint; + const copy = failureCopy(result); + heading.textContent = copy.heading; + detail.textContent = copy.hint; }; + input.addEventListener("input", syncDisabled); paint(initial); syncDisabled(); - - const submit = (): void => { + button.addEventListener("click", () => { const token = input.value; - if (token.length === 0) { - return; - } + if (token.length === 0) return; button.disabled = true; - // Clear the input immediately: the token is now in flight to the shell - // and must never linger UI-side (DL-109). No binding retains it. input.value = ""; void deps.shellConnect(token).then((result) => { if (result.ok) { - resolve( - deps.nativeConnectionProvider(shellServerUrl() ?? "").resolve(), - ); + void deps + .nativeConnectionProvider(shellServerUrl() ?? "") + .resolve() + .then(resolve); return; } - // Retry in place: re-render the matching failure state, keep the - // screen up, and re-enable submit once the user types again. paint(result); syncDisabled(); }); - }; - button.addEventListener("click", submit); + }); + root.replaceChildren(screen); + }); +} +function awaitUserSetupConnect( + root: HTMLElement, + deps: NativeBootDeps, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) { + root.replaceChildren(); + return Promise.resolve(undefined); + } + return new Promise((resolve) => { + const screen = document.createElement("div"); + screen.setAttribute("style", SCREEN_STYLE); + const heading = document.createElement("h1"); + heading.setAttribute("style", HEADING_STYLE); + heading.textContent = "Connect to a server"; + const detail = document.createElement("p"); + detail.setAttribute("style", DETAIL_STYLE); + const url = document.createElement("input"); + url.setAttribute("style", INPUT_STYLE); + url.type = "url"; + url.placeholder = "https://your-server.example"; + url.autocomplete = "off"; + const chooseCA = document.createElement("button"); + chooseCA.setAttribute("style", BUTTON_STYLE); + chooseCA.type = "button"; + chooseCA.textContent = "Choose CA certificate…"; + const caRow = document.createElement("p"); + caRow.setAttribute("style", URL_STYLE); + caRow.textContent = "System trust"; + const systemTrust = document.createElement("button"); + systemTrust.setAttribute("style", BUTTON_STYLE); + systemTrust.type = "button"; + systemTrust.textContent = "Use system trust"; + const token = document.createElement("input"); + token.setAttribute("style", INPUT_STYLE); + token.type = "password"; + token.placeholder = "Paste your token"; + token.autocomplete = "off"; + const submit = document.createElement("button"); + submit.setAttribute("style", BUTTON_STYLE); + submit.type = "button"; + submit.textContent = "Connect"; + screen.append( + heading, + detail, + url, + chooseCA, + caRow, + systemTrust, + token, + submit, + ); + + let caRef = ""; + let shellCallInFlight = false; + let finished = false; + const finish = (connection: ResolvedConnection | undefined): void => { + if (finished) return; + finished = true; + signal?.removeEventListener("abort", onAbort); + resolve(connection); + }; + const onAbort = (): void => { + if (signal?.aborted && !shellCallInFlight) { + root.replaceChildren(); + finish(undefined); + } + }; + const syncDisabled = (): void => { + submit.disabled = + url.value.length === 0 || token.value.length === 0 || shellCallInFlight; + }; + const paintResult = (result: ConnectResult): void => { + const copy = failureCopy(result); + detail.textContent = copy.hint; + }; + url.addEventListener("input", syncDisabled); + token.addEventListener("input", syncDisabled); + signal?.addEventListener("abort", onAbort, { once: true }); + chooseCA.addEventListener("click", () => { + void deps.pickCACert().then((picked) => { + if (finished || signal?.aborted) return; + caRef = picked.ref; + caRow.textContent = + picked.ref.length === 0 ? "System trust" : picked.name; + }); + }); + systemTrust.addEventListener("click", () => { + caRef = ""; + caRow.textContent = "System trust"; + }); + submit.addEventListener("click", () => { + if ( + finished || + shellCallInFlight || + url.value.length === 0 || + token.value.length === 0 + ) + return; + const server: ServerChoice = { url: url.value, caRef }; + const secret = token.value; + token.value = ""; + shellCallInFlight = true; + syncDisabled(); + void deps.shellConnect(secret, server).then((result) => { + shellCallInFlight = false; + if (result.ok) { + signal?.removeEventListener("abort", onAbort); + void deps + .nativeConnectionProvider(result.serverUrl) + .resolve() + .then(finish); + return; + } + if (signal?.aborted) { + root.replaceChildren(); + finish(undefined); + return; + } + paintResult(result); + syncDisabled(); + }); + }); root.replaceChildren(screen); + syncDisabled(); + onAbort(); }); } diff --git a/apps/ui/src/boot-setup.test.ts b/apps/ui/src/boot-setup.test.ts new file mode 100644 index 000000000..9fd0724c1 --- /dev/null +++ b/apps/ui/src/boot-setup.test.ts @@ -0,0 +1,374 @@ +/// +import { beforeEach, describe, expect, test } from "bun:test"; +import { bootNativeClient, type NativeBootDeps } from "./boot-native"; +import { bootSetup, type SetupBootDeps } from "./boot-setup"; +import type { ConnectResult, SetupResult } from "./daemon-transport"; +import type { ResolvedConnection } from "./live/provider"; + +const CONNECTION: ResolvedConnection = { + baseUrl: "https://compass.example", + token: undefined, + fetchImpl: undefined, +}; + +let root: HTMLElement; +let state: { + mode: "setup" | "client" | "reopen" | "embedded"; + serverUrl: string; +}; +let stateCalls: number; +let chooseResult: Promise; +let setupDecided: (() => void) | undefined; +let unsubscribeCalls: number; +let nativeCalls: Array<{ + root: HTMLElement; + entry: "setup" | "configured"; + signal: AbortSignal | undefined; +}>; +let nativeResolvers: Array<(result: ResolvedConnection | undefined) => void>; +let deps: SetupBootDeps; + +beforeEach(() => { + root = document.createElement("div"); + state = { mode: "setup", serverUrl: "" }; + stateCalls = 0; + chooseResult = Promise.resolve({ ok: false, message: "preflight failed" }); + setupDecided = undefined; + unsubscribeCalls = 0; + nativeCalls = []; + nativeResolvers = []; + deps = { + chooseEmbedded: () => chooseResult, + shellState: async () => { + expect(setupDecided).toBeDefined(); + stateCalls++; + return state; + }, + onSetupDecided: (fn) => { + setupDecided = fn; + return () => { + unsubscribeCalls++; + }; + }, + quitApp: async () => {}, + bootNativeClient: (receivedRoot, entry, signal) => { + nativeCalls.push({ root: receivedRoot, entry, signal }); + const { promise, resolve } = Promise.withResolvers< + ResolvedConnection | undefined + >(); + nativeResolvers.push(resolve); + return promise; + }, + }; +}); + +async function flush(): Promise { + for (let i = 0; i < 8; i++) await Promise.resolve(); +} + +function button(label: string): HTMLButtonElement { + const found = [...root.querySelectorAll("button")].find( + (item) => item.textContent === label, + ); + if (!(found instanceof HTMLButtonElement)) + throw new Error(`missing button ${label}`); + return found; +} + +function emitDecision(): void { + if (!setupDecided) throw new Error("setup:decided listener is not installed"); + setupDecided(); +} + +function connectDeps( + pending: Array<(result: ConnectResult) => void>, +): NativeBootDeps { + return { + shellConnect: () => { + const { promise, resolve } = Promise.withResolvers(); + pending.push(resolve); + return promise; + }, + pickCACert: async () => ({ ref: "", name: "" }), + nativeConnectionProvider: (baseUrl) => ({ + resolve: async () => ({ + baseUrl, + token: undefined, + fetchImpl: undefined, + }), + }), + }; +} + +function connectResult(over: Partial): ConnectResult { + return { + ok: false, + kind: "other", + message: "", + accountId: "", + serverVersion: "", + apiVersion: "", + serverUrl: "https://compass.example", + ...over, + }; +} + +describe("bootSetup", () => { + test("an embedded failure returns to both choices with its message", async () => { + const { promise, resolve } = Promise.withResolvers(); + chooseResult = promise; + void bootSetup(root, deps); + await flush(); + button("Run Compass on this computer").click(); + expect(root.textContent).toContain( + "Checking this computer… The first check on a Mac can take several minutes.", + ); + resolve({ ok: false, message: "preflight could not start" }); + await flush(); + + expect(root.textContent).toContain("preflight could not start"); + expect(root.textContent).toContain("Connect to a server"); + expect(root.textContent).toContain("Run Compass on this computer"); + expect(unsubscribeCalls).toBe(0); + }); + + test("embedded success is terminal and Quit calls quitApp", async () => { + let quitCalls = 0; + deps.quitApp = async () => { + quitCalls++; + }; + chooseResult = Promise.resolve({ + ok: true, + message: + "Compass is set up to run on this computer. Quit and reopen it to start.", + }); + void bootSetup(root, deps); + await flush(); + button("Run Compass on this computer").click(); + await flush(); + + expect(root.textContent).toContain( + "Compass is set up to run on this computer. Quit and reopen it to start.", + ); + button("Quit").click(); + await flush(); + expect(quitCalls).toBe(1); + expect(unsubscribeCalls).toBe(1); + }); + + test("setup decisions show reopen or boot the configured client once", async () => { + void bootSetup(root, deps); + await flush(); + state = { mode: "reopen", serverUrl: "" }; + emitDecision(); + await flush(); + expect(root.textContent).toContain( + "Compass is already set up. Quit and reopen it to change this.", + ); + expect(nativeCalls).toHaveLength(0); + expect(unsubscribeCalls).toBe(1); + + root = document.createElement("div"); + state = { mode: "setup", serverUrl: "" }; + stateCalls = 0; + unsubscribeCalls = 0; + nativeCalls = []; + nativeResolvers = []; + void bootSetup(root, deps); + await flush(); + state = { mode: "client", serverUrl: "https://compass.example" }; + emitDecision(); + await flush(); + expect(nativeCalls.map((call) => call.entry)).toEqual(["configured"]); + expect(nativeCalls[0]?.root).toBe(root); + nativeResolvers[0]?.(CONNECTION); + await flush(); + expect(unsubscribeCalls).toBe(1); + }); + + test("a decision made before subscription is found by the first shellState", async () => { + state = { mode: "reopen", serverUrl: "" }; + const booted = bootSetup(root, deps); + await flush(); + + expect(stateCalls).toBe(1); + expect(root.textContent).toContain( + "Compass is already set up. Quit and reopen it to change this.", + ); + expect(nativeCalls).toHaveLength(0); + expect(await booted).toBeUndefined(); + expect(unsubscribeCalls).toBe(1); + }); + + test("the window that chose embedded keeps its success screen when its event arrives", async () => { + const { promise, resolve } = Promise.withResolvers(); + chooseResult = promise; + void bootSetup(root, deps); + await flush(); + button("Run Compass on this computer").click(); + state = { mode: "reopen", serverUrl: "" }; + emitDecision(); + expect(stateCalls).toBe(1); + resolve({ + ok: true, + message: + "Compass is set up to run on this computer. Quit and reopen it to start.", + }); + await flush(); + + expect(root.textContent).toContain( + "Compass is set up to run on this computer. Quit and reopen it to start.", + ); + expect(root.textContent).not.toContain("Compass is already set up."); + expect(stateCalls).toBe(1); + expect(unsubscribeCalls).toBe(1); + }); + + test("failed and refused attempts keep listening for a sibling decision", async () => { + const results = [ + { ok: false, message: "preflight failed" }, + { ok: false, message: "Another window is setting up Compass." }, + ]; + let index = 0; + deps.chooseEmbedded = async () => { + const result = results[index]; + if (!result) throw new Error("no configured result"); + index++; + return result; + }; + void bootSetup(root, deps); + await flush(); + button("Run Compass on this computer").click(); + await flush(); + expect(root.textContent).toContain("preflight failed"); + expect(unsubscribeCalls).toBe(0); + button("Run Compass on this computer").click(); + await flush(); + expect(root.textContent).toContain("Another window is setting up Compass."); + expect(unsubscribeCalls).toBe(0); + + state = { mode: "reopen", serverUrl: "" }; + emitDecision(); + await flush(); + expect(root.textContent).toContain( + "Compass is already set up. Quit and reopen it to change this.", + ); + expect(stateCalls).toBe(2); + expect(unsubscribeCalls).toBe(1); + }); + + test("a sibling decision during an idle setup form aborts and applies new state", async () => { + const pending: Array<(result: ConnectResult) => void> = []; + const nativeDeps = connectDeps(pending); + const setupDeps: SetupBootDeps = { + ...deps, + bootNativeClient: (receivedRoot, entry, signal) => + bootNativeClient(receivedRoot, nativeDeps, entry, signal), + }; + void bootSetup(root, setupDeps); + await flush(); + button("Connect to a server").click(); + await flush(); + state = { mode: "reopen", serverUrl: "" }; + emitDecision(); + expect(root.childElementCount).toBe(0); + await flush(); + + expect(stateCalls).toBe(2); + expect(root.textContent).toContain( + "Compass is already set up. Quit and reopen it to change this.", + ); + }); + + test("a non-ok in-flight connect returns undefined before the state reread", async () => { + const pending: Array<(result: ConnectResult) => void> = []; + const nativeDeps = connectDeps(pending); + const setupDeps: SetupBootDeps = { + ...deps, + bootNativeClient: (receivedRoot, entry, signal) => + bootNativeClient(receivedRoot, nativeDeps, entry, signal), + }; + void bootSetup(root, setupDeps); + await flush(); + button("Connect to a server").click(); + await flush(); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) + throw new Error("missing setup fields"); + url.value = "https://new.example"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + button("Connect").click(); + state = { mode: "reopen", serverUrl: "" }; + emitDecision(); + expect(stateCalls).toBe(1); + expect(root.textContent).toContain("Connect to a server"); + const settle = pending[0]; + if (!settle) throw new Error("no in-flight shellConnect"); + settle(connectResult({ kind: "invalid-url", message: "bad URL" })); + await flush(); + expect(stateCalls).toBe(2); + expect(root.textContent).toContain( + "Compass is already set up. Quit and reopen it to change this.", + ); + }); + + test("an ok in-flight connect wins over an abort and does not read sibling state", async () => { + const pending: Array<(result: ConnectResult) => void> = []; + const nativeDeps = connectDeps(pending); + const setupDeps: SetupBootDeps = { + ...deps, + bootNativeClient: (receivedRoot, entry, signal) => + bootNativeClient(receivedRoot, nativeDeps, entry, signal), + }; + const booted = bootSetup(root, setupDeps); + await flush(); + button("Connect to a server").click(); + await flush(); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) + throw new Error("missing setup fields"); + url.value = "https://new.example"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + button("Connect").click(); + state = { mode: "reopen", serverUrl: "" }; + emitDecision(); + const settle = pending[0]; + if (!settle) throw new Error("no in-flight shellConnect"); + settle( + connectResult({ ok: true, kind: "", serverUrl: "https://new.example" }), + ); + expect(await booted).toEqual({ + baseUrl: "https://new.example", + token: undefined, + fetchImpl: undefined, + }); + expect(stateCalls).toBe(1); + expect(root.textContent).not.toContain("Compass is already set up."); + }); + + test("connect hands off setup entry with a live abort signal", async () => { + const booted = bootSetup(root, deps); + await flush(); + button("Connect to a server").click(); + await flush(); + expect(nativeCalls).toHaveLength(1); + expect(nativeCalls[0]?.entry).toBe("setup"); + expect(nativeCalls[0]?.root).toBe(root); + expect(nativeCalls[0]?.signal?.aborted).toBe(false); + nativeResolvers[0]?.(CONNECTION); + expect(await booted).toBe(CONNECTION); + expect(unsubscribeCalls).toBe(1); + }); +}); diff --git a/apps/ui/src/boot-setup.ts b/apps/ui/src/boot-setup.ts new file mode 100644 index 000000000..83fe99ca0 --- /dev/null +++ b/apps/ui/src/boot-setup.ts @@ -0,0 +1,242 @@ +import { bootNativeClient } from "./boot-native"; +import { + BUTTON_STYLE, + DETAIL_STYLE, + HEADING_STYLE, + SCREEN_STYLE, +} from "./boot-styles"; +import { + chooseEmbedded, + onSetupDecided, + quitApp, + type SetupResult, + shellState, +} from "./daemon-transport"; +import type { ResolvedConnection } from "./live/provider"; +import type { ShellMode } from "./shell-globals"; + +const ALREADY_SET_UP = + "Compass is already set up. Quit and reopen it to change this."; + +export type SetupBootDeps = { + chooseEmbedded: () => Promise; + shellState: () => Promise<{ mode: ShellMode; serverUrl: string }>; + onSetupDecided: (fn: () => void) => () => void; + quitApp: () => Promise; + bootNativeClient: ( + root: HTMLElement, + entry: "setup" | "configured", + signal?: AbortSignal, + ) => Promise; +}; + +const defaultSetupBootDeps: SetupBootDeps = { + chooseEmbedded, + shellState, + onSetupDecided, + quitApp, + bootNativeClient: (root, entry, signal) => + bootNativeClient(root, undefined, entry, signal), +}; + +/** Show first-run choices and follow decisions made by other windows. */ +export async function bootSetup( + root: HTMLElement, + deps: SetupBootDeps = defaultSetupBootDeps, +): Promise { + let terminal = false; + let chooseInFlight = false; + let stateReadInFlight = false; + let decisionQueued = false; + let connectController: AbortController | undefined; + let resolveBoot: + | ((value: ResolvedConnection | undefined) => void) + | undefined; + let unsubscribe: () => void = () => {}; + + const booted = new Promise((resolve) => { + resolveBoot = resolve; + }); + + const closeListener = (): void => { + unsubscribe(); + }; + + const renderReopen = (): void => { + terminal = true; + closeListener(); + renderTerminal(root, ALREADY_SET_UP, deps.quitApp); + resolveBoot?.(undefined); + }; + + const renderEmbeddedReady = (message: string): void => { + terminal = true; + closeListener(); + renderTerminal(root, message, deps.quitApp); + resolveBoot?.(undefined); + }; + + let choose: () => Promise; + let connect: () => void; + + const readShellState = async (): Promise => { + if (terminal || stateReadInFlight || chooseInFlight) { + decisionQueued = true; + return; + } + stateReadInFlight = true; + try { + const current = await deps.shellState(); + if (terminal) return; + switch (current.mode) { + case "client": { + terminal = true; + closeListener(); + connectController?.abort(); + const connection = await deps.bootNativeClient(root, "configured"); + resolveBoot?.(connection); + return; + } + case "reopen": + renderReopen(); + return; + case "setup": + case "embedded": + renderChoices(root, choose, connect); + return; + default: { + const exhaustive: never = current.mode; + throw new Error(`Unhandled shell mode: ${exhaustive}`); + } + } + } finally { + stateReadInFlight = false; + if (decisionQueued && !terminal && !chooseInFlight) { + decisionQueued = false; + void readShellState(); + } + } + }; + + const onDecision = (): void => { + if (terminal) return; + if (chooseInFlight || stateReadInFlight) { + decisionQueued = true; + return; + } + if (connectController) { + connectController.abort(); + return; + } + void readShellState(); + }; + + connect = (): void => { + if (terminal || connectController) return; + connectController = new AbortController(); + const setupConnection = deps.bootNativeClient( + root, + "setup", + connectController.signal, + ); + void setupConnection.then(async (connection) => { + if (terminal) return; + if (connection) { + terminal = true; + closeListener(); + resolveBoot?.(connection); + return; + } + connectController = undefined; + await readShellState(); + }); + }; + + choose = async (): Promise => { + if (terminal || chooseInFlight) return; + chooseInFlight = true; + decisionQueued = false; + renderChecking(root); + let result: SetupResult; + try { + result = await deps.chooseEmbedded(); + } finally { + chooseInFlight = false; + } + if (terminal) return; + if (result.ok) { + renderEmbeddedReady(result.message); + return; + } + if (decisionQueued) { + decisionQueued = false; + await readShellState(); + return; + } + if (result.message === ALREADY_SET_UP) { + renderReopen(); + return; + } + renderChoices(root, choose, connect, result.message); + }; + + unsubscribe = deps.onSetupDecided(onDecision); + await readShellState(); + return await booted; +} + +function renderChoices( + root: HTMLElement, + choose: () => Promise, + connect: () => void, + message = "", +): void { + const screen = document.createElement("div"); + screen.setAttribute("style", SCREEN_STYLE); + const heading = document.createElement("h1"); + heading.setAttribute("style", HEADING_STYLE); + heading.textContent = "Set up Compass"; + const detail = document.createElement("p"); + detail.setAttribute("style", DETAIL_STYLE); + detail.textContent = message; + const embedded = document.createElement("button"); + embedded.setAttribute("style", BUTTON_STYLE); + embedded.type = "button"; + embedded.textContent = "Run Compass on this computer"; + embedded.addEventListener("click", () => void choose()); + const connectButton = document.createElement("button"); + connectButton.setAttribute("style", BUTTON_STYLE); + connectButton.type = "button"; + connectButton.textContent = "Connect to a server"; + connectButton.addEventListener("click", connect); + screen.append(heading, detail, embedded, connectButton); + root.replaceChildren(screen); +} + +function renderChecking(root: HTMLElement): void { + const screen = document.createElement("div"); + screen.setAttribute("style", SCREEN_STYLE); + const detail = document.createElement("p"); + detail.textContent = + "Checking this computer… The first check on a Mac can take several minutes."; + screen.append(detail); + root.replaceChildren(screen); +} + +function renderTerminal( + root: HTMLElement, + message: string, + quit: () => Promise, +): void { + const screen = document.createElement("div"); + screen.setAttribute("style", SCREEN_STYLE); + const detail = document.createElement("p"); + detail.textContent = message; + const button = document.createElement("button"); + button.setAttribute("style", BUTTON_STYLE); + button.type = "button"; + button.textContent = "Quit"; + button.addEventListener("click", () => void quit()); + screen.append(detail, button); + root.replaceChildren(screen); +} diff --git a/apps/ui/src/daemon-transport.ts b/apps/ui/src/daemon-transport.ts index 5c398e023..b1155a6be 100644 --- a/apps/ui/src/daemon-transport.ts +++ b/apps/ui/src/daemon-transport.ts @@ -57,7 +57,10 @@ export function createDaemonFetch(ipc: ShellIpc): DaemonFetch { input: RequestInfo | URL, init?: RequestInit, ): Promise => { - const request = new Request(input as RequestInfo, init); + const request = + input instanceof Request + ? new Request(input, init) + : new Request(input.toString(), init); const url = new URL(request.url); const path = url.pathname + url.search; @@ -173,7 +176,7 @@ export function createDaemonFetch(ipc: ShellIpc): DaemonFetch { // subscribes to the per-request runtime event BEFORE invoking `CompassRPC`, delivers each // `ResponseFrame` to `onFrame`, and unsubscribes on the terminal frame; `cancel` invokes // `CompassRPCCancel`. The Go shell emits one runtime event per ordered frame. -import { Call, Events } from "@wailsio/runtime"; +import { Application, Call, Events } from "@wailsio/runtime"; import type { ConnectionProvider, ResolvedConnection } from "./live/provider"; // The fully-qualified names of the bound Go methods, as the Wails generator computes them @@ -182,6 +185,9 @@ import type { ConnectionProvider, ResolvedConnection } from "./live/provider"; const RPC_METHOD = "main.bridgeService.CompassRPC"; const RPC_CANCEL_METHOD = "main.bridgeService.CompassRPCCancel"; const CONNECT_METHOD = "main.bridgeService.Connect"; +const PICK_CA_METHOD = "main.dialogService.PickCACert"; +const CHOOSE_EMBEDDED_METHOD = "main.setupService.ChooseEmbedded"; +const SHELL_STATE_METHOD = "main.bridgeService.ShellState"; /** Build the Wails binding of the shell IPC seam. `rpc` wires the response-frame * subscription up before firing the call so no frame can race ahead of the @@ -238,21 +244,135 @@ export type ConnectResult = { | "bad-cert" | "bad-token" | "version-mismatch" + | "invalid-url" + | "invalid-ca" | "other"; message: string; accountId: string; serverVersion: string; apiVersion: string; + serverUrl: string; }; -/** Invoke the Go shell's `Connect` bound method by name, passing the pasted - * token (or `""` for the boot-internal "use the stored one" probe, T5.5), and - * return its classified result. The method lives in the (unmerged) T5.3 stack; - * it is called purely by string name through the Wails runtime, never imported, - * so this compiles and is testable against a fake runtime without the Go method - * existing on main. */ -export function shellConnect(token: string): Promise { - return Call.ByName(CONNECT_METHOD, { token }) as Promise; +export type ServerChoice = { url: string; caRef: string }; +export type PickedCA = { ref: string; name: string }; +export type SetupResult = { ok: boolean; message: string }; + +export type ShellMode = "embedded" | "client" | "setup" | "reopen"; +export type ShellState = { mode: ShellMode; serverUrl: string }; + +function isConnectResult(value: unknown): value is ConnectResult { + if (value === null || typeof value !== "object") return false; + if ( + !("ok" in value) || + !("kind" in value) || + !("message" in value) || + !("accountId" in value) || + !("serverVersion" in value) || + !("apiVersion" in value) || + !("serverUrl" in value) + ) + return false; + const kinds: ConnectResult["kind"][] = [ + "", + "bad-url", + "bad-cert", + "bad-token", + "version-mismatch", + "invalid-url", + "invalid-ca", + "other", + ]; + return ( + typeof value.ok === "boolean" && + typeof value.kind === "string" && + kinds.some((kind) => kind === value.kind) && + typeof value.message === "string" && + typeof value.accountId === "string" && + typeof value.serverVersion === "string" && + typeof value.apiVersion === "string" && + typeof value.serverUrl === "string" + ); +} + +function isPickedCA(value: unknown): value is PickedCA { + return ( + value !== null && + typeof value === "object" && + "ref" in value && + typeof value.ref === "string" && + "name" in value && + typeof value.name === "string" + ); +} + +function isSetupResult(value: unknown): value is SetupResult { + return ( + value !== null && + typeof value === "object" && + "ok" in value && + typeof value.ok === "boolean" && + "message" in value && + typeof value.message === "string" + ); +} + +function isShellState(value: unknown): value is ShellState { + if ( + value === null || + typeof value !== "object" || + !("mode" in value) || + !("serverUrl" in value) + ) + return false; + return ( + (value.mode === "embedded" || + value.mode === "client" || + value.mode === "setup" || + value.mode === "reopen") && + typeof value.serverUrl === "string" + ); +} + +/** Call the shell's configured or first-run connect method. */ +export async function shellConnect( + token: string, + server?: ServerChoice, +): Promise { + const request = server === undefined ? { token } : { token, server }; + const result: unknown = await Call.ByName(CONNECT_METHOD, request); + if (!isConnectResult(result)) + throw new TypeError("Invalid Connect result from shell"); + return result; +} + +export async function pickCACert(): Promise { + const picked: unknown = await Call.ByName(PICK_CA_METHOD); + if (!isPickedCA(picked)) + throw new TypeError("Invalid PickCACert result from shell"); + return picked; +} + +export async function chooseEmbedded(): Promise { + const result: unknown = await Call.ByName(CHOOSE_EMBEDDED_METHOD); + if (!isSetupResult(result)) + throw new TypeError("Invalid ChooseEmbedded result from shell"); + return result; +} + +export async function shellState(): Promise { + const result: unknown = await Call.ByName(SHELL_STATE_METHOD); + if (!isShellState(result)) + throw new TypeError("Invalid ShellState result from shell"); + return result; +} + +export function onSetupDecided(fn: () => void): () => void { + return Events.On("setup:decided", fn); +} + +export async function quitApp(): Promise { + await Application.Quit(); } /** The native (desktop-shell) connection provider. `resolve()` hands back the diff --git a/apps/ui/src/daemon-transport.wails.test.ts b/apps/ui/src/daemon-transport.wails.test.ts index 82ae12f37..cae5e335e 100644 --- a/apps/ui/src/daemon-transport.wails.test.ts +++ b/apps/ui/src/daemon-transport.wails.test.ts @@ -11,8 +11,8 @@ // - nativeConnectionProvider().resolve() yields token === undefined (DL-109: // the UI-side Connection never carries a bearer in client mode) and a defined // fetchImpl. -// - shellConnect(token) invokes the Connect method by name with the token and -// maps the returned ConnectResult through faithfully (ok and failure kinds). +// the server choice and returns the expanded ConnectResult. +// - setup bindings call their bound Go methods with the specified JSON shapes. // // The Wails runtime is a hand-installed fake via mock.module: Events.On records // each subscription and hands back an unsubscribe that flips a flag, and @@ -23,8 +23,13 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import * as realRuntime from "@wailsio/runtime"; import { + chooseEmbedded, nativeConnectionProvider, + onSetupDecided, + pickCACert, + quitApp, shellConnect, + shellState, wailsShellIpc, } from "./daemon-transport"; @@ -47,6 +52,7 @@ type Invocation = { let subscriptions: Subscription[]; let calls: Invocation[]; +let quitCalls: number; /** Install a fresh fake `@wailsio/runtime` for a test. Bun's `mock.module` * retroactively updates the live ESM binding, so the statically-imported @@ -55,7 +61,14 @@ let calls: Invocation[]; function installFakeRuntime(): void { subscriptions = []; calls = []; + quitCalls = 0; mock.module("@wailsio/runtime", () => ({ + Application: { + Quit() { + quitCalls++; + return Promise.resolve(); + }, + }, Events: { On(name: string, cb: (event: { name: string; data: unknown }) => void) { const sub: Subscription = { name, cb, off: false }; @@ -197,6 +210,7 @@ describe("shellConnect", () => { accountId: "acc-1", serverVersion: "1.2.3", apiVersion: "compass.v1", + serverUrl: "https://compass.example", }); const result = await promise; expect(result.ok).toBe(true); @@ -215,6 +229,7 @@ describe("shellConnect", () => { accountId: "", serverVersion: "", apiVersion: "", + serverUrl: "", }); const result = await promise; expect(result.ok).toBe(false); @@ -222,3 +237,68 @@ describe("shellConnect", () => { expect(result.message).toBe("the token was rejected"); }); }); +describe("setup bindings", () => { + test("send server choice and call the exact setup methods", async () => { + const connect = shellConnect("first-token", { + url: "https://host", + caRef: "ca-ref", + }); + expect(calls[0]?.method).toBe("main.bridgeService.Connect"); + expect(calls[0]?.args).toEqual([ + { + token: "first-token", + server: { url: "https://host", caRef: "ca-ref" }, + }, + ]); + calls[0]?.resolve({ + ok: true, + kind: "", + message: "", + accountId: "", + serverVersion: "", + apiVersion: "", + serverUrl: "https://host", + }); + expect(await connect).toMatchObject({ + ok: true, + serverUrl: "https://host", + }); + + const picked = pickCACert(); + expect(calls[1]?.method).toBe("main.dialogService.PickCACert"); + calls[1]?.resolve({ ref: "opaque-ref", name: "root.pem" }); + expect(await picked).toEqual({ ref: "opaque-ref", name: "root.pem" }); + + const embedded = chooseEmbedded(); + expect(calls[2]?.method).toBe("main.setupService.ChooseEmbedded"); + calls[2]?.resolve({ ok: false, message: "not ready" }); + expect(await embedded).toEqual({ ok: false, message: "not ready" }); + + const state = shellState(); + expect(calls[3]?.method).toBe("main.bridgeService.ShellState"); + calls[3]?.resolve({ mode: "setup", serverUrl: "" }); + expect(await state).toEqual({ mode: "setup", serverUrl: "" }); + + const off = onSetupDecided(() => {}); + expect(subscriptions[0]?.name).toBe("setup:decided"); + off(); + expect(subscriptions[0]?.off).toBe(true); + await quitApp(); + expect(quitCalls).toBe(1); + }); + + test("send only token for a configured connect", async () => { + const connect = shellConnect("configured-token"); + expect(calls[0]?.args).toEqual([{ token: "configured-token" }]); + calls[0]?.resolve({ + ok: false, + kind: "invalid-ca", + message: "bad CA", + accountId: "", + serverVersion: "", + apiVersion: "", + serverUrl: "", + }); + expect((await connect).kind).toBe("invalid-ca"); + }); +}); diff --git a/apps/ui/src/shell-globals.test.ts b/apps/ui/src/shell-globals.test.ts index 2a0bea7d0..2baeee9bf 100644 --- a/apps/ui/src/shell-globals.test.ts +++ b/apps/ui/src/shell-globals.test.ts @@ -33,4 +33,10 @@ describe("shellMode / shellServerUrl", () => { w.__COMPASS_MODE__ = "embedded"; expect(shellMode()).toBe("embedded"); }); + test("recognizes setup and reopen shell modes", () => { + w.__COMPASS_MODE__ = "setup"; + expect(shellMode()).toBe("setup"); + w.__COMPASS_MODE__ = "reopen"; + expect(shellMode()).toBe("reopen"); + }); }); diff --git a/apps/ui/src/shell-globals.ts b/apps/ui/src/shell-globals.ts index 08eb2b054..8fad739f1 100644 --- a/apps/ui/src/shell-globals.ts +++ b/apps/ui/src/shell-globals.ts @@ -6,7 +6,7 @@ /** The shell-injected launch mode. Client boots the connect-screen probe; * embedded resolves the bridge connection directly. Owned here — the single * source of truth both the injected global and every boot consumer name. */ -export type ShellMode = "embedded" | "client"; +export type ShellMode = "embedded" | "client" | "setup" | "reopen"; declare global { interface Window { From d07e5688f19ab8c118cebe9d91be9e53eec7ebca Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 5 Oct 2026 22:45:37 -0400 Subject: [PATCH 2/4] fix(ui): recover setup screen from shell failures and sibling races Accepts today's ConnectResult without serverUrl and falls back to the submitted URL. A rejected chooseEmbedded, shellConnect, shellState or CA pick leaves a retryable screen; a sibling client decision sets the live server URL before handoff; setup success clears the form; state reads re-check active work; reopen shares one renderer; a cancelled CA dialog keeps the selection. Refs RIG-4564 Co-authored-by: Matt Wilkinson --- apps/ui/src/boot-mode.test.ts | 13 +- apps/ui/src/boot-mode.ts | 25 +- apps/ui/src/boot-native.test.ts | 313 +++++++++++++++++++-- apps/ui/src/boot-native.ts | 122 +++++--- apps/ui/src/boot-setup.test.ts | 166 ++++++++++- apps/ui/src/boot-setup.ts | 206 +++++++++----- apps/ui/src/daemon-transport.ts | 56 +++- apps/ui/src/daemon-transport.wails.test.ts | 28 +- apps/ui/src/shell-globals.test.ts | 9 +- apps/ui/src/shell-globals.ts | 5 + 10 files changed, 770 insertions(+), 173 deletions(-) diff --git a/apps/ui/src/boot-mode.test.ts b/apps/ui/src/boot-mode.test.ts index ac500b087..62952df11 100644 --- a/apps/ui/src/boot-mode.test.ts +++ b/apps/ui/src/boot-mode.test.ts @@ -24,8 +24,8 @@ describe("bootForMode", () => { let embeddedFactoryCalls: number; let browserCalls: number; let connectionBootCalls: number; + let quitCalls: number; let deps: BootModeDeps; - beforeEach(() => { root = document.createElement("div"); setupCalls = 0; @@ -33,6 +33,7 @@ describe("bootForMode", () => { embeddedFactoryCalls = 0; browserCalls = 0; connectionBootCalls = 0; + quitCalls = 0; deps = { bootNativeClient: async (receivedRoot) => { expect(receivedRoot).toBe(root); @@ -58,6 +59,9 @@ describe("bootForMode", () => { connectionBootCalls++; return resolve(); }, + quitApp: async () => { + quitCalls++; + }, }; }); @@ -100,12 +104,17 @@ describe("bootForMode", () => { expect(clientCalls).toBe(0); }); - test("reopen renders the neutral screen and never boots a connection", async () => { + test("reopen renders the shared neutral screen and never boots a connection", async () => { await bootForMode("reopen", root, deps)(); expect(root.textContent).toContain( "Compass is already set up. Quit and reopen it to change this.", ); + const button = root.querySelector("button"); + if (!(button instanceof HTMLButtonElement)) + throw new Error("Quit button is missing"); + button.click(); + expect(quitCalls).toBe(1); expect(clientCalls).toBe(0); expect(browserCalls).toBe(0); expect(connectionBootCalls).toBe(0); diff --git a/apps/ui/src/boot-mode.ts b/apps/ui/src/boot-mode.ts index 59ac7d8fe..24353de28 100644 --- a/apps/ui/src/boot-mode.ts +++ b/apps/ui/src/boot-mode.ts @@ -1,7 +1,7 @@ import { bootConnection } from "./boot"; import { bootBrowser } from "./boot-browser"; import { bootNativeClient } from "./boot-native"; -import { bootSetup } from "./boot-setup"; +import { bootSetup, renderReopenScreen } from "./boot-setup"; import { nativeConnectionProvider, quitApp } from "./daemon-transport"; import type { ConnectionProvider, ResolvedConnection } from "./live/provider"; import { type ShellMode, shellServerUrl } from "./shell-globals"; @@ -19,34 +19,19 @@ export type BootModeDeps = { root: HTMLElement, resolve: () => Promise, ) => Promise; + quitApp: () => Promise; }; - export const defaultDeps: BootModeDeps = { bootNativeClient, bootSetup, + // The daemon fetch constructs Requests from this base, so it must be absolute. embeddedConnectionProvider: () => nativeConnectionProvider(shellServerUrl() ?? "http://compass.localhost"), bootBrowser, bootConnection, + quitApp, }; -function showReopen(root: HTMLElement): void { - const screen = document.createElement("div"); - screen.setAttribute( - "style", - "margin:0;padding:2rem;font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e6e6e6;background:#1a1a1a;min-height:100vh", - ); - const message = document.createElement("p"); - message.textContent = - "Compass is already set up. Quit and reopen it to change this."; - const button = document.createElement("button"); - button.type = "button"; - button.textContent = "Quit"; - button.addEventListener("click", () => void quitApp()); - screen.append(message, button); - root.replaceChildren(screen); -} - export function bootForMode( mode: BootMode, root: HTMLElement, @@ -64,7 +49,7 @@ export function bootForMode( return () => deps.bootSetup(root); case "reopen": return async () => { - showReopen(root); + renderReopenScreen(root, deps.quitApp); return undefined; }; case undefined: diff --git a/apps/ui/src/boot-native.test.ts b/apps/ui/src/boot-native.test.ts index 4f9cd7666..078ddfd00 100644 --- a/apps/ui/src/boot-native.test.ts +++ b/apps/ui/src/boot-native.test.ts @@ -4,15 +4,12 @@ import { bootNativeClient, type NativeBootDeps } from "./boot-native"; import type { ConnectResult, PickedCA, ServerChoice } from "./daemon-transport"; import type { ConnectionProvider, ResolvedConnection } from "./live/provider"; -// The native client boot gate consumes injected shell transport dependencies. -// Tests drive calls directly without Wails, IPC, or a live network. -// -// The transport records server choices and captures each pending shell call. - -// The transport records the optional first-run server choice per call. +// Native boot tests drive shell calls through injected dependencies. +// The fake records choices and lets pending calls settle or reject. let connectTokens: string[]; let serverChoices: Array; let pending: Array<(result: ConnectResult) => void>; +let pendingReject: Array<(reason: unknown) => void>; let pickedCA: PickedCA; let deps: NativeBootDeps; @@ -39,6 +36,7 @@ beforeEach(() => { connectTokens = []; serverChoices = []; pending = []; + pendingReject = []; pickedCA = { ref: "", name: "" }; deps = { shellConnect: ( @@ -47,8 +45,10 @@ beforeEach(() => { ): Promise => { connectTokens.push(token); serverChoices.push(server); - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve, reject } = + Promise.withResolvers(); pending.push(resolve); + pendingReject.push(reject); return promise; }, pickCACert: async (): Promise => pickedCA, @@ -60,11 +60,6 @@ beforeEach(() => { }; window.__COMPASS_SERVER_URL__ = "https://compass.example:8443"; }); - -afterEach(() => { - window.__COMPASS_SERVER_URL__ = undefined; -}); - /** Settle the Nth (0-based) outstanding shellConnect call. */ function settle(index: number, result: ConnectResult): void { const resolve = pending[index]; @@ -73,15 +68,269 @@ function settle(index: number, result: ConnectResult): void { resolve(result); } -/** Drain the microtask queue so the gate's resolved-promise `.then` callbacks - * (and the render they perform) run — deterministic, no wall-clock timer. A few - * ticks cover the short then-chain (settle → resolve provider → paint). */ +/** Flush the short promise chain after driving a fake shell call. */ async function flush(): Promise { - for (let i = 0; i < 5; i++) { - await Promise.resolve(); - } + for (let i = 0; i < 5; i++) await Promise.resolve(); } +afterEach(() => { + window.__COMPASS_SERVER_URL__ = undefined; +}); + +test("setup success clears the form and falls back when the result omits serverUrl", async () => { + const root = document.createElement("div"); + const booted = bootNativeClient(root, deps, "setup"); + await flush(); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) + throw new Error("setup form inputs are missing"); + url.value = "https://submitted.example"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + const connect = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ); + if (!(connect instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); + connect.click(); + await flush(); + const legacyResult: ConnectResult = { + ok: true, + kind: "", + message: "", + accountId: "", + serverVersion: "", + apiVersion: "", + }; + settle(0, legacyResult); + expect((await booted)?.baseUrl).toBe("https://submitted.example"); + expect(root.childElementCount).toBe(0); +}); + +test("rejected setup connect clears the in-flight state and allows retry", async () => { + const root = document.createElement("div"); + const booted = bootNativeClient(root, deps, "setup"); + await flush(); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) + throw new Error("setup form inputs are missing"); + url.value = "https://submitted.example"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + const connect = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ); + if (!(connect instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); + connect.click(); + const rejectCall = pendingReject[0]; + if (!rejectCall) throw new Error("no pending shellConnect rejection"); + rejectCall(new Error("temporary IPC failure")); + await flush(); + expect(root.textContent).toContain("Could not connect"); + expect(root.textContent).toContain("Try again."); + token.value = "retry-token"; + token.dispatchEvent(new Event("input")); + expect(connect.disabled).toBe(false); + connect.click(); + await flush(); + expect(connectTokens).toEqual(["token", "retry-token"]); + settle(1, connectResult({ ok: true, kind: "" })); + expect((await booted)?.baseUrl).toBe("https://compass.example:8443"); + expect(root.childElementCount).toBe(0); +}); + +test("a rejected setup connect after abort resolves undefined", async () => { + const root = document.createElement("div"); + const controller = new AbortController(); + const booted = bootNativeClient(root, deps, "setup", controller.signal); + await flush(); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) + throw new Error("setup form inputs are missing"); + url.value = "https://submitted.example"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + const connect = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ); + if (!(connect instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); + connect.click(); + await flush(); + controller.abort(); + const rejectCall = pendingReject[0]; + if (!rejectCall) throw new Error("no pending shellConnect rejection"); + rejectCall(new Error("late IPC failure")); + + expect(await booted).toBeUndefined(); + expect(root.childElementCount).toBe(0); +}); + +test("a rejected or cancelled CA dialog preserves the current certificate", async () => { + let picks = 0; + deps.pickCACert = async () => { + picks++; + if (picks === 1) return { ref: "saved-ref", name: "saved.pem" }; + if (picks === 2) return { ref: "", name: "cancelled" }; + throw new Error("dialog unavailable"); + }; + const root = document.createElement("div"); + const controller = new AbortController(); + const booted = bootNativeClient(root, deps, "setup", controller.signal); + await flush(); + const choose = root.querySelectorAll("button").item(0); + if (!(choose instanceof HTMLButtonElement)) + throw new Error("CA button is missing"); + choose.click(); + await flush(); + expect(root.textContent).toContain("saved.pem"); + choose.click(); + await flush(); + expect(root.textContent).toContain("saved.pem"); + choose.click(); + await flush(); + expect(root.textContent).toContain("saved.pem"); + expect(root.textContent).toContain("dialog unavailable"); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) + throw new Error("setup form inputs are missing"); + url.value = "https://submitted.example"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + const connect = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ); + if (!(connect instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); + connect.click(); + await flush(); + expect(serverChoices).toEqual([ + { url: "https://submitted.example", caRef: "saved-ref" }, + ]); + settle(0, connectResult({ kind: "bad-token" })); + await flush(); + controller.abort(); + expect(await booted).toBeUndefined(); +}); + +test("setup failures retain useful headings and point to the CA picker", async () => { + const root = document.createElement("div"); + const controller = new AbortController(); + const booted = bootNativeClient(root, deps, "setup", controller.signal); + await flush(); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) + throw new Error("setup form inputs are missing"); + url.value = "https://submitted.example"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + const connect = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ); + if (!(connect instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); + connect.click(); + await flush(); + settle(0, connectResult({ kind: "bad-cert" })); + await flush(); + + expect(root.textContent).toContain("Can't verify the server's certificate"); + expect(root.textContent).toContain("Use Choose CA certificate… above"); + expect(root.textContent).not.toContain("ca_cert"); + token.value = "retry-token"; + token.dispatchEvent(new Event("input")); + connect.click(); + await flush(); + settle( + 1, + connectResult({ kind: "version-mismatch", apiVersion: "compass.v2" }), + ); + await flush(); + expect(root.textContent).toContain( + "App speaks compass.v1; server speaks compass.v2", + ); + controller.abort(); + await expect(booted).resolves.toBeUndefined(); +}); + +test("setup provider rejection reaches its caller", async () => { + deps.nativeConnectionProvider = () => ({ + resolve: async () => { + throw new Error("provider unavailable"); + }, + }); + const root = document.createElement("div"); + const booted = bootNativeClient(root, deps, "setup"); + await flush(); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) + throw new Error("setup form inputs are missing"); + url.value = "https://submitted.example"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + const connect = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ); + if (!(connect instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); + connect.click(); + await flush(); + settle( + 0, + connectResult({ + ok: true, + kind: "", + serverUrl: "https://submitted.example", + }), + ); + await expect(booted).rejects.toThrow("provider unavailable"); +}); + +test("configured provider rejection reaches its caller", async () => { + deps.nativeConnectionProvider = () => ({ + resolve: async () => { + throw new Error("configured provider unavailable"); + }, + }); + const root = document.createElement("div"); + const booted = bootNativeClient(root, { + ...deps, + shellConnect: async () => connectResult({ ok: true, kind: "" }), + }); + await expect(booted).rejects.toThrow("configured provider unavailable"); +}); + describe("bootNativeClient — the boot gate", () => { test("renders the connecting state before the probe settles", async () => { const root = document.createElement("div"); @@ -160,7 +409,9 @@ describe("bootNativeClient — the boot gate", () => { settle(0, connectResult({ kind: "bad-token" })); await flush(); - const button = root.querySelector("button") as HTMLButtonElement; + const button = root.querySelector("button"); + if (!(button instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); expect(button.disabled).toBe(true); // A click on the disabled/empty form must not fire a second shellConnect — @@ -178,8 +429,12 @@ describe("bootNativeClient — the boot gate", () => { settle(0, connectResult({ kind: "bad-token" })); await flush(); - const input = root.querySelector("input") as HTMLInputElement; - const button = root.querySelector("button") as HTMLButtonElement; + const input = root.querySelector("input"); + const button = root.querySelector("button"); + if (!(input instanceof HTMLInputElement)) + throw new Error("token input is missing"); + if (!(button instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); input.value = "secret-token"; input.dispatchEvent(new Event("input")); @@ -202,8 +457,12 @@ describe("bootNativeClient — the boot gate", () => { settle(0, connectResult({ kind: "bad-token" })); await flush(); - const input = root.querySelector("input") as HTMLInputElement; - const button = root.querySelector("button") as HTMLButtonElement; + const input = root.querySelector("input"); + const button = root.querySelector("button"); + if (!(input instanceof HTMLInputElement)) + throw new Error("token input is missing"); + if (!(button instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); input.value = "nope"; input.dispatchEvent(new Event("input")); button.click(); @@ -225,8 +484,12 @@ describe("bootNativeClient — the boot gate", () => { settle(0, connectResult({ kind: "bad-token" })); await flush(); - const input = root.querySelector("input") as HTMLInputElement; - const button = root.querySelector("button") as HTMLButtonElement; + const input = root.querySelector("input"); + const button = root.querySelector("button"); + if (!(input instanceof HTMLInputElement)) + throw new Error("token input is missing"); + if (!(button instanceof HTMLButtonElement)) + throw new Error("connect button is missing"); input.value = "good-token"; input.dispatchEvent(new Event("input")); button.click(); diff --git a/apps/ui/src/boot-native.ts b/apps/ui/src/boot-native.ts index d6be31b96..99c3e4cbb 100644 --- a/apps/ui/src/boot-native.ts +++ b/apps/ui/src/boot-native.ts @@ -43,7 +43,7 @@ function failureCopy(result: ConnectResult): { heading: string; hint: string } { case "bad-cert": return { heading: "Can't verify the server's certificate", - hint: "The server's TLS certificate could not be verified — check its ca_cert, then try again.", + hint: "Check the server certificate or choose its CA certificate, then try again.", }; case "bad-token": return { @@ -76,10 +76,10 @@ export async function bootNativeClient( signal?: AbortSignal, ): Promise { if (entry === "setup") return awaitUserSetupConnect(root, deps, signal); - renderConnecting(root); const probe = await deps.shellConnect(""); if (probe.ok) { + root.replaceChildren(); return deps.nativeConnectionProvider(shellServerUrl() ?? "").resolve(); } return awaitUserConnect(root, probe, deps); @@ -103,7 +103,7 @@ function awaitUserConnect( initial: ConnectResult, deps: NativeBootDeps, ): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const screen = document.createElement("div"); screen.setAttribute("style", SCREEN_STYLE); const heading = document.createElement("h1"); @@ -140,17 +140,22 @@ function awaitUserConnect( if (token.length === 0) return; button.disabled = true; input.value = ""; - void deps.shellConnect(token).then((result) => { - if (result.ok) { - void deps - .nativeConnectionProvider(shellServerUrl() ?? "") - .resolve() - .then(resolve); - return; - } - paint(result); - syncDisabled(); - }); + void deps + .shellConnect(token) + .then(async (result) => { + if (result.ok) { + root.replaceChildren(); + resolve( + await deps + .nativeConnectionProvider(shellServerUrl() ?? "") + .resolve(), + ); + return; + } + paint(result); + syncDisabled(); + }) + .catch(reject); }); root.replaceChildren(screen); }); @@ -165,7 +170,7 @@ function awaitUserSetupConnect( root.replaceChildren(); return Promise.resolve(undefined); } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const screen = document.createElement("div"); screen.setAttribute("style", SCREEN_STYLE); const heading = document.createElement("h1"); @@ -218,6 +223,12 @@ function awaitUserSetupConnect( signal?.removeEventListener("abort", onAbort); resolve(connection); }; + const fail = (reason: unknown): void => { + if (finished) return; + finished = true; + signal?.removeEventListener("abort", onAbort); + reject(reason); + }; const onAbort = (): void => { if (signal?.aborted && !shellCallInFlight) { root.replaceChildren(); @@ -230,18 +241,29 @@ function awaitUserSetupConnect( }; const paintResult = (result: ConnectResult): void => { const copy = failureCopy(result); - detail.textContent = copy.hint; + heading.textContent = copy.heading; + detail.textContent = + result.kind === "bad-cert" + ? "Use Choose CA certificate… above to select the server's CA, then try again." + : copy.hint; }; url.addEventListener("input", syncDisabled); token.addEventListener("input", syncDisabled); signal?.addEventListener("abort", onAbort, { once: true }); chooseCA.addEventListener("click", () => { - void deps.pickCACert().then((picked) => { - if (finished || signal?.aborted) return; - caRef = picked.ref; - caRow.textContent = - picked.ref.length === 0 ? "System trust" : picked.name; - }); + void deps.pickCACert().then( + (picked) => { + if (finished || signal?.aborted) return; + if (picked.ref.length === 0) return; + caRef = picked.ref; + caRow.textContent = picked.name; + }, + (reason: unknown) => { + if (finished || signal?.aborted) return; + detail.textContent = + reason instanceof Error ? reason.message : String(reason); + }, + ); }); systemTrust.addEventListener("click", () => { caRef = ""; @@ -260,24 +282,44 @@ function awaitUserSetupConnect( token.value = ""; shellCallInFlight = true; syncDisabled(); - void deps.shellConnect(secret, server).then((result) => { - shellCallInFlight = false; - if (result.ok) { - signal?.removeEventListener("abort", onAbort); - void deps - .nativeConnectionProvider(result.serverUrl) - .resolve() - .then(finish); - return; - } - if (signal?.aborted) { - root.replaceChildren(); - finish(undefined); - return; - } - paintResult(result); - syncDisabled(); - }); + void deps.shellConnect(secret, server).then( + async (result) => { + shellCallInFlight = false; + if (result.ok) { + signal?.removeEventListener("abort", onAbort); + root.replaceChildren(); + try { + const connection = await deps + .nativeConnectionProvider(result.serverUrl ?? server.url) + .resolve(); + finish(connection); + } catch (reason) { + fail(reason); + } + return; + } + if (signal?.aborted) { + root.replaceChildren(); + finish(undefined); + return; + } + paintResult(result); + syncDisabled(); + }, + (reason: unknown) => { + shellCallInFlight = false; + if (signal?.aborted) { + root.replaceChildren(); + finish(undefined); + return; + } + const message = + reason instanceof Error ? reason.message : String(reason); + heading.textContent = "Could not connect"; + detail.textContent = `Try again. ${message}`; + syncDisabled(); + }, + ); }); root.replaceChildren(screen); syncDisabled(); diff --git a/apps/ui/src/boot-setup.test.ts b/apps/ui/src/boot-setup.test.ts index 9fd0724c1..7aad25a24 100644 --- a/apps/ui/src/boot-setup.test.ts +++ b/apps/ui/src/boot-setup.test.ts @@ -2,7 +2,11 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { bootNativeClient, type NativeBootDeps } from "./boot-native"; import { bootSetup, type SetupBootDeps } from "./boot-setup"; -import type { ConnectResult, SetupResult } from "./daemon-transport"; +import type { + ConnectResult, + SetupResult, + ShellState, +} from "./daemon-transport"; import type { ResolvedConnection } from "./live/provider"; const CONNECTION: ResolvedConnection = { @@ -186,6 +190,166 @@ describe("bootSetup", () => { expect(unsubscribeCalls).toBe(1); }); + test("a sibling client decision passes its server URL to configured boot", async () => { + const nativeDeps: NativeBootDeps = { + ...connectDeps([]), + shellConnect: async () => connectResult({ ok: true, kind: "" }), + }; + const setupDeps: SetupBootDeps = { + ...deps, + bootNativeClient: (receivedRoot, entry, signal) => + bootNativeClient(receivedRoot, nativeDeps, entry, signal), + }; + const booted = bootSetup(root, setupDeps); + await flush(); + state = { mode: "client", serverUrl: "https://sibling.example" }; + emitDecision(); + expect((await booted)?.baseUrl).toBe("https://sibling.example"); + }); + + test("a rejected shell-state call renders recoverable setup choices", async () => { + let calls = 0; + deps.shellState = async () => { + calls++; + if (calls === 1) throw new Error("state unavailable"); + return { mode: "setup", serverUrl: "" }; + }; + void bootSetup(root, deps); + await flush(); + expect(root.textContent).toContain("state unavailable"); + button("Connect to a server").click(); + await flush(); + expect(nativeCalls.map((call) => call.entry)).toEqual(["setup"]); + }); + test("a rejected embedded choice restores both choices", async () => { + deps.chooseEmbedded = async () => { + throw new Error("preflight unavailable"); + }; + void bootSetup(root, deps); + await flush(); + button("Run Compass on this computer").click(); + await flush(); + expect(root.textContent).toContain("preflight unavailable"); + button("Connect to a server").click(); + await flush(); + expect(nativeCalls.map((call) => call.entry)).toEqual(["setup"]); + }); + test("a rejected embedded choice rereads a queued sibling decision", async () => { + const choose = Promise.withResolvers(); + deps.chooseEmbedded = () => choose.promise; + const booted = bootSetup(root, deps); + await flush(); + button("Run Compass on this computer").click(); + state = { mode: "reopen", serverUrl: "" }; + emitDecision(); + choose.reject(new Error("preflight unavailable")); + await flush(); + + expect(stateCalls).toBe(2); + expect(root.textContent).toContain( + "Compass is already set up. Quit and reopen it to change this.", + ); + expect(root.textContent).not.toContain("preflight unavailable"); + expect(unsubscribeCalls).toBe(1); + expect(await booted).toBeUndefined(); + }); + + test("a queued sibling event does not overwrite an embedded failure", async () => { + const read = Promise.withResolvers(); + let calls = 0; + deps.shellState = async () => { + calls++; + return calls === 2 ? read.promise : state; + }; + const choose = Promise.withResolvers(); + deps.chooseEmbedded = () => choose.promise; + void bootSetup(root, deps); + await flush(); + emitDecision(); + await flush(); + button("Run Compass on this computer").click(); + read.resolve({ mode: "setup", serverUrl: "" }); + await flush(); + choose.reject(new Error("preflight failed after sibling event")); + await flush(); + expect(calls).toBe(3); + expect(root.textContent).toContain("preflight failed after sibling event"); + expect(root.textContent).toContain("Run Compass on this computer"); + expect(root.textContent).toContain("Connect to a server"); + }); + test("a non-terminal embedded failure rereads a queued sibling decision", async () => { + const choose = Promise.withResolvers(); + chooseResult = choose.promise; + const booted = bootSetup(root, deps); + await flush(); + button("Run Compass on this computer").click(); + emitDecision(); + state = { mode: "reopen", serverUrl: "" }; + choose.resolve({ ok: false, message: "preflight failed" }); + await flush(); + + expect(stateCalls).toBe(2); + expect(root.textContent).toContain( + "Compass is already set up. Quit and reopen it to change this.", + ); + expect(root.textContent).not.toContain("preflight failed"); + expect(await booted).toBeUndefined(); + expect(unsubscribeCalls).toBe(1); + }); + + test("a state read completing during an embedded attempt defers until failure", async () => { + const read = Promise.withResolvers(); + let calls = 0; + deps.shellState = async () => { + calls++; + return calls === 2 ? read.promise : state; + }; + const choose = Promise.withResolvers(); + deps.chooseEmbedded = () => choose.promise; + void bootSetup(root, deps); + await flush(); + emitDecision(); + await flush(); + button("Run Compass on this computer").click(); + read.resolve({ mode: "setup", serverUrl: "" }); + await flush(); + + expect(root.textContent).toContain("Checking this computer"); + expect(calls).toBe(2); + choose.resolve({ ok: false, message: "preflight failed" }); + await flush(); + expect(calls).toBe(3); + expect(root.textContent).toContain("preflight failed"); + expect(root.textContent).toContain("Connect to a server"); + }); + + test("a state read completing during connect aborts and rereads terminal state", async () => { + const read = Promise.withResolvers(); + let calls = 0; + deps.shellState = async () => { + calls++; + return calls === 2 ? read.promise : state; + }; + const booted = bootSetup(root, deps); + await flush(); + emitDecision(); + await flush(); + button("Connect to a server").click(); + state = { mode: "reopen", serverUrl: "" }; + read.resolve(state); + await flush(); + + const signal = nativeCalls[0]?.signal; + expect(signal?.aborted).toBe(true); + nativeResolvers[0]?.(undefined); + await flush(); + expect(calls).toBe(3); + expect(await booted).toBeUndefined(); + expect(root.textContent).toContain( + "Compass is already set up. Quit and reopen it to change this.", + ); + }); + test("a decision made before subscription is found by the first shellState", async () => { state = { mode: "reopen", serverUrl: "" }; const booted = bootSetup(root, deps); diff --git a/apps/ui/src/boot-setup.ts b/apps/ui/src/boot-setup.ts index 83fe99ca0..bb5ffb1c7 100644 --- a/apps/ui/src/boot-setup.ts +++ b/apps/ui/src/boot-setup.ts @@ -10,12 +10,13 @@ import { onSetupDecided, quitApp, type SetupResult, + type ShellState, shellState, } from "./daemon-transport"; import type { ResolvedConnection } from "./live/provider"; -import type { ShellMode } from "./shell-globals"; +import { type ShellMode, setShellServerUrl } from "./shell-globals"; -const ALREADY_SET_UP = +export const REOPEN_MESSAGE = "Compass is already set up. Quit and reopen it to change this."; export type SetupBootDeps = { @@ -52,85 +53,118 @@ export async function bootSetup( let resolveBoot: | ((value: ResolvedConnection | undefined) => void) | undefined; + let rejectBoot: ((reason: unknown) => void) | undefined; let unsubscribe: () => void = () => {}; - - const booted = new Promise((resolve) => { - resolveBoot = resolve; - }); - - const closeListener = (): void => { - unsubscribe(); - }; - - const renderReopen = (): void => { + const booted = new Promise( + (resolve, reject) => { + resolveBoot = resolve; + rejectBoot = reject; + }, + ); + const closeListener = (): void => unsubscribe(); + const showReopen = (): void => { terminal = true; + connectController?.abort(); closeListener(); - renderTerminal(root, ALREADY_SET_UP, deps.quitApp); + renderReopenScreen(root, deps.quitApp); resolveBoot?.(undefined); }; - const renderEmbeddedReady = (message: string): void => { terminal = true; closeListener(); renderTerminal(root, message, deps.quitApp); resolveBoot?.(undefined); }; - let choose: () => Promise; let connect: () => void; - - const readShellState = async (): Promise => { - if (terminal || stateReadInFlight || chooseInFlight) { + const deferStateRead = (): boolean => { + if (!stateReadInFlight && !chooseInFlight) return false; + decisionQueued = true; + return true; + }; + const applyShellState = async ( + current: ShellState, + message: string, + ): Promise => { + if (terminal) return; + if (chooseInFlight || connectController) { decisionQueued = true; + if ( + connectController && + (current.mode === "client" || current.mode === "reopen") + ) + connectController.abort(); return; } - stateReadInFlight = true; - try { - const current = await deps.shellState(); - if (terminal) return; - switch (current.mode) { - case "client": { - terminal = true; - closeListener(); - connectController?.abort(); + switch (current.mode) { + case "client": { + terminal = true; + closeListener(); + setShellServerUrl(current.serverUrl); + try { const connection = await deps.bootNativeClient(root, "configured"); resolveBoot?.(connection); - return; - } - case "reopen": - renderReopen(); - return; - case "setup": - case "embedded": - renderChoices(root, choose, connect); - return; - default: { - const exhaustive: never = current.mode; - throw new Error(`Unhandled shell mode: ${exhaustive}`); + } catch (reason) { + rejectBoot?.(reason); } + return; } - } finally { - stateReadInFlight = false; - if (decisionQueued && !terminal && !chooseInFlight) { - decisionQueued = false; - void readShellState(); + case "reopen": + showReopen(); + return; + case "setup": + case "embedded": + renderChoices(root, choose, connect, message); + return; + default: { + const exhaustive: never = current.mode; + throw new Error(`Unhandled shell mode: ${exhaustive}`); } } }; - - const onDecision = (): void => { - if (terminal) return; - if (chooseInFlight || stateReadInFlight) { - decisionQueued = true; + const handleShellStateError = (reason: unknown): void => { + if (terminal) rejectBoot?.(reason); + else if (!chooseInFlight && !connectController) + renderChoices(root, choose, connect, errorMessage(reason)); + }; + const finishShellStateRead = (): void => { + stateReadInFlight = false; + if (!decisionQueued || terminal || chooseInFlight || connectController) return; + decisionQueued = false; + void readShellState(); + }; + const readShellState = async (message = ""): Promise => { + if (terminal || deferStateRead()) return; + stateReadInFlight = true; + try { + const current = await deps.shellState(); + if (chooseInFlight || connectController) { + decisionQueued = true; + if ( + connectController && + (current.mode === "client" || current.mode === "reopen") + ) + connectController.abort(); + } else { + await applyShellState(current, message); + } + } catch (reason) { + handleShellStateError(reason); + } finally { + finishShellStateRead(); } + }; + const onDecision = (): void => { + if (terminal) return; + if (deferStateRead()) return; if (connectController) { + decisionQueued = true; connectController.abort(); return; } void readShellState(); }; - connect = (): void => { if (terminal || connectController) return; connectController = new AbortController(); @@ -139,30 +173,57 @@ export async function bootSetup( "setup", connectController.signal, ); - void setupConnection.then(async (connection) => { - if (terminal) return; - if (connection) { - terminal = true; - closeListener(); - resolveBoot?.(connection); - return; - } - connectController = undefined; - await readShellState(); - }); + void setupConnection.then( + (connection) => { + if (terminal) return; + if (connection) { + connectController = undefined; + terminal = true; + closeListener(); + resolveBoot?.(connection); + } else { + connectController = undefined; + if (decisionQueued) { + decisionQueued = false; + void readShellState(); + } + } + }, + (reason: unknown) => { + if (terminal) return; + connectController = undefined; + renderChoices(root, choose, connect, errorMessage(reason)); + if (decisionQueued) { + decisionQueued = false; + void readShellState(); + } + }, + ); }; - - choose = async (): Promise => { + const handleEmbeddedChoice = async (): Promise => { if (terminal || chooseInFlight) return; chooseInFlight = true; - decisionQueued = false; renderChecking(root); let result: SetupResult; try { result = await deps.chooseEmbedded(); + } catch (reason) { + chooseInFlight = false; + if (terminal) return; + if (decisionQueued) { + decisionQueued = false; + await readShellState(errorMessage(reason)); + return; + } + renderChoices(root, choose, connect, errorMessage(reason)); + return; } finally { chooseInFlight = false; } + await applyEmbeddedResult(result); + }; + choose = handleEmbeddedChoice; + const applyEmbeddedResult = async (result: SetupResult): Promise => { if (terminal) return; if (result.ok) { renderEmbeddedReady(result.message); @@ -170,21 +231,30 @@ export async function bootSetup( } if (decisionQueued) { decisionQueued = false; - await readShellState(); + await readShellState(result.message); return; } - if (result.message === ALREADY_SET_UP) { - renderReopen(); + if (result.message === REOPEN_MESSAGE) { + showReopen(); return; } renderChoices(root, choose, connect, result.message); }; - unsubscribe = deps.onSetupDecided(onDecision); await readShellState(); return await booted; } +function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason); +} + +export function renderReopenScreen( + root: HTMLElement, + quit: () => Promise, +): void { + renderTerminal(root, REOPEN_MESSAGE, quit); +} function renderChoices( root: HTMLElement, choose: () => Promise, diff --git a/apps/ui/src/daemon-transport.ts b/apps/ui/src/daemon-transport.ts index b1155a6be..250ab9bd2 100644 --- a/apps/ui/src/daemon-transport.ts +++ b/apps/ui/src/daemon-transport.ts @@ -39,12 +39,10 @@ function decodeChunk(b64: string): Uint8Array { return out; } -/** The fetch this module produces: only the inputs the gRPC-Web transport sets. */ type DaemonFetch = ( input: RequestInfo | URL, init?: RequestInit, ) => Promise; - /** * Build a `fetch` that proxies gRPC-Web calls to the daemon over the given * `ShellIpc`. Only the inputs the gRPC-Web transport actually sets cross the @@ -208,7 +206,8 @@ export function wailsShellIpc(): ShellIpc { off = undefined; }; off = Events.On(eventName, (event: Events.WailsEvent) => { - const frame = event.data as ResponseFrame; + const frame: unknown = event.data; + if (!isResponseFrame(frame)) return; onFrame(frame); if (frame.kind === "end" || frame.kind === "error") unsubscribe(); }); @@ -226,7 +225,7 @@ export function wailsShellIpc(): ShellIpc { }, cancel(requestId) { // Best-effort: swallow a cancel that races the proxy finishing (an - // unknown/already-finished id is a no-op on the Go side). + // unknown/already-finished id is a no-op on the Rust side). Call.ByName(RPC_CANCEL_METHOD, { requestId }).catch(() => {}); }, }; @@ -251,7 +250,7 @@ export type ConnectResult = { accountId: string; serverVersion: string; apiVersion: string; - serverUrl: string; + serverUrl?: string; }; export type ServerChoice = { url: string; caRef: string }; @@ -269,8 +268,7 @@ function isConnectResult(value: unknown): value is ConnectResult { !("message" in value) || !("accountId" in value) || !("serverVersion" in value) || - !("apiVersion" in value) || - !("serverUrl" in value) + !("apiVersion" in value) ) return false; const kinds: ConnectResult["kind"][] = [ @@ -291,7 +289,7 @@ function isConnectResult(value: unknown): value is ConnectResult { typeof value.accountId === "string" && typeof value.serverVersion === "string" && typeof value.apiVersion === "string" && - typeof value.serverUrl === "string" + (!("serverUrl" in value) || typeof value.serverUrl === "string") ); } @@ -334,7 +332,38 @@ function isShellState(value: unknown): value is ShellState { ); } -/** Call the shell's configured or first-run connect method. */ +function isHeaderPair(value: unknown): value is [string, string] { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === "string" && + typeof value[1] === "string" + ); +} + +function isResponseFrame(value: unknown): value is ResponseFrame { + if (value === null || typeof value !== "object" || !("kind" in value)) + return false; + switch (value.kind) { + case "head": + return ( + "status" in value && + typeof value.status === "number" && + "headers" in value && + Array.isArray(value.headers) && + value.headers.every((header) => isHeaderPair(header)) + ); + case "body": + return "chunk" in value && typeof value.chunk === "string"; + case "end": + return true; + case "error": + return "message" in value && typeof value.message === "string"; + default: + return false; + } +} + export async function shellConnect( token: string, server?: ServerChoice, @@ -384,10 +413,11 @@ export async function quitApp(): Promise { export function nativeConnectionProvider(baseUrl: string): ConnectionProvider { return { async resolve(): Promise { - // The transport only ever invokes the call signature of `fetch`; the - // daemon fetch deliberately omits `fetch.preconnect` (a no-op over the - // IPC tunnel), so widen it to the `typeof fetch` the seam declares. - const fetchImpl = createDaemonFetch(wailsShellIpc()) as typeof fetch; + // The native transport tunnels requests over IPC, so preconnect has no + // socket to warm and is intentionally a no-op. + const fetchImpl = Object.assign(createDaemonFetch(wailsShellIpc()), { + preconnect: (_url: string | URL): void => {}, + }); return { baseUrl, token: undefined, fetchImpl }; }, }; diff --git a/apps/ui/src/daemon-transport.wails.test.ts b/apps/ui/src/daemon-transport.wails.test.ts index cae5e335e..09fa2c4d2 100644 --- a/apps/ui/src/daemon-transport.wails.test.ts +++ b/apps/ui/src/daemon-transport.wails.test.ts @@ -11,8 +11,8 @@ // - nativeConnectionProvider().resolve() yields token === undefined (DL-109: // the UI-side Connection never carries a bearer in client mode) and a defined // fetchImpl. -// the server choice and returns the expanded ConnectResult. -// - setup bindings call their bound Go methods with the specified JSON shapes. +// - shellConnect sends the optional server choice and maps the Go ConnectResult, +// which may omit serverUrl until the shell support for it lands. // // The Wails runtime is a hand-installed fake via mock.module: Events.On records // each subscription and hands back an unsubscribe that flips a flag, and @@ -142,6 +142,15 @@ describe("wailsShellIpc", () => { expect(seen).toEqual(["head", "body", "end"]); }); + test("does not deliver a malformed runtime frame to the transport", () => { + const ipc = wailsShellIpc(); + const seen: string[] = []; + void ipc.rpc(rpcArgs, (frame) => seen.push(frame.kind)); + + emit("compass_rpc:req-1", { kind: "body", chunk: 42 }); + expect(seen).toEqual([]); + }); + test("unsubscribes on the terminal end frame — a later frame never reaches onFrame", async () => { const ipc = wailsShellIpc(); const seen: string[] = []; @@ -220,6 +229,21 @@ describe("shellConnect", () => { expect(result.apiVersion).toBe("compass.v1"); }); + test("accepts the current Go ConnectResult without serverUrl", async () => { + const promise = shellConnect(""); + calls[0]?.resolve({ + ok: true, + kind: "", + message: "", + accountId: "acc-1", + serverVersion: "1.2.3", + apiVersion: "compass.v1", + }); + const result = await promise; + expect(result.ok).toBe(true); + expect(result.serverUrl).toBeUndefined(); + }); + test("maps a failure-kind result through faithfully", async () => { const promise = shellConnect("bad"); calls[0].resolve({ diff --git a/apps/ui/src/shell-globals.test.ts b/apps/ui/src/shell-globals.test.ts index 2baeee9bf..2778d06f7 100644 --- a/apps/ui/src/shell-globals.test.ts +++ b/apps/ui/src/shell-globals.test.ts @@ -1,6 +1,6 @@ /// import { afterEach, describe, expect, test } from "bun:test"; -import { shellMode, shellServerUrl } from "./shell-globals"; +import { setShellServerUrl, shellMode, shellServerUrl } from "./shell-globals"; // The two shell-injected startup globals are the synchronous, no-IPC source of // truth for launch mode + server URL (OQ-8). The readers must return whatever @@ -8,7 +8,7 @@ import { shellMode, shellServerUrl } from "./shell-globals"; // without throwing. `window` exists here under happy-dom, so absence is the // undefined value, not a missing global object. -const w = window as Window; +const w = window; afterEach(() => { w.__COMPASS_MODE__ = undefined; @@ -39,4 +39,9 @@ describe("shellMode / shellServerUrl", () => { w.__COMPASS_MODE__ = "reopen"; expect(shellMode()).toBe("reopen"); }); + + test("updates the live server URL for a configured handoff", () => { + setShellServerUrl("https://sibling.example"); + expect(shellServerUrl()).toBe("https://sibling.example"); + }); }); diff --git a/apps/ui/src/shell-globals.ts b/apps/ui/src/shell-globals.ts index 8fad739f1..3e5a709a6 100644 --- a/apps/ui/src/shell-globals.ts +++ b/apps/ui/src/shell-globals.ts @@ -32,3 +32,8 @@ export function shellServerUrl(): string | undefined { } return window.__COMPASS_SERVER_URL__; } + +/** Store the server URL returned with a sibling window's client decision. */ +export function setShellServerUrl(serverUrl: string): void { + if (typeof window !== "undefined") window.__COMPASS_SERVER_URL__ = serverUrl; +} From 63bfa9445dd916a1f6d6c7cfe3e623062f57c24f Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 6 Oct 2026 01:03:45 -0400 Subject: [PATCH 3/4] fix(ui): settle bad shell frames and keep setup decisions across failures An invalid Wails response frame becomes an RPC error and unsubscribes, so the pending fetch rejects; head status must be 200-599 and omitted optional fields are accepted. A setup success without serverUrl is a retryable failure. Queued sibling decisions and error messages survive failed or deferred state reads; the configured retry form handles a rejected connect locally. Refs RIG-4564 Co-authored-by: Matt Wilkinson --- apps/ui/src/boot-native.test.ts | 86 ++++++++++- apps/ui/src/boot-native.ts | 103 +++++++------ apps/ui/src/boot-setup.test.ts | 81 +++++++++- apps/ui/src/boot-setup.ts | 86 ++++++++--- apps/ui/src/daemon-transport.ts | 52 +++++-- apps/ui/src/daemon-transport.wails.test.ts | 168 ++++++++++++++++++--- 6 files changed, 470 insertions(+), 106 deletions(-) diff --git a/apps/ui/src/boot-native.test.ts b/apps/ui/src/boot-native.test.ts index 078ddfd00..d8d014732 100644 --- a/apps/ui/src/boot-native.test.ts +++ b/apps/ui/src/boot-native.test.ts @@ -77,7 +77,7 @@ afterEach(() => { window.__COMPASS_SERVER_URL__ = undefined; }); -test("setup success clears the form and falls back when the result omits serverUrl", async () => { +test("setup rejects success without serverUrl and retries with the returned URL", async () => { const root = document.createElement("div"); const booted = bootNativeClient(root, deps, "setup"); await flush(); @@ -89,7 +89,7 @@ test("setup success clears the form and falls back when the result omits serverU !(token instanceof HTMLInputElement) ) throw new Error("setup form inputs are missing"); - url.value = "https://submitted.example"; + url.value = "https://submitted.example/path"; token.value = "token"; token.dispatchEvent(new Event("input")); const connect = [...root.querySelectorAll("button")].find( @@ -99,16 +99,32 @@ test("setup success clears the form and falls back when the result omits serverU throw new Error("connect button is missing"); connect.click(); await flush(); - const legacyResult: ConnectResult = { + settle(0, { ok: true, kind: "", message: "", accountId: "", serverVersion: "", apiVersion: "", - }; - settle(0, legacyResult); - expect((await booted)?.baseUrl).toBe("https://submitted.example"); + }); + await flush(); + + expect(root.textContent).toContain("Could not connect"); + expect(root.querySelectorAll("input")).toHaveLength(2); + token.value = "retry-token"; + token.dispatchEvent(new Event("input")); + connect.click(); + await flush(); + settle( + 1, + connectResult({ + ok: true, + kind: "", + serverUrl: "https://normalized.example", + }), + ); + + expect((await booted)?.baseUrl).toBe("https://normalized.example"); expect(root.childElementCount).toBe(0); }); @@ -330,6 +346,64 @@ test("configured provider rejection reaches its caller", async () => { }); await expect(booted).rejects.toThrow("configured provider unavailable"); }); +test("configured retry propagates native provider rejection", async () => { + deps.nativeConnectionProvider = () => ({ + resolve: async () => { + throw new Error("retry provider unavailable"); + }, + }); + const root = document.createElement("div"); + const booted = bootNativeClient(root, deps); + await flush(); + settle(0, connectResult({ kind: "bad-token" })); + await flush(); + const input = root.querySelector("input"); + const button = root.querySelector("button"); + if ( + !(input instanceof HTMLInputElement) || + !(button instanceof HTMLButtonElement) + ) + throw new Error("configured form is missing"); + input.value = "retry-token"; + input.dispatchEvent(new Event("input")); + button.click(); + await flush(); + settle(1, connectResult({ ok: true, kind: "" })); + + await expect(booted).rejects.toThrow("retry provider unavailable"); +}); +test("a rejected configured retry keeps the form available", async () => { + let retries = 0; + deps.shellConnect = async (token) => { + if (token.length === 0) return connectResult({ kind: "bad-token" }); + retries++; + if (retries === 1) throw new Error("temporary IPC failure"); + return connectResult({ ok: true, kind: "" }); + }; + const root = document.createElement("div"); + const booted = bootNativeClient(root, deps); + await flush(); + const input = root.querySelector("input"); + const connect = root.querySelector("button"); + if ( + !(input instanceof HTMLInputElement) || + !(connect instanceof HTMLButtonElement) + ) + throw new Error("configured retry form is missing"); + input.value = "first-token"; + input.dispatchEvent(new Event("input")); + connect.click(); + await flush(); + + expect(root.textContent).toContain("temporary IPC failure"); + expect(root.querySelector("input")).toBe(input); + expect(connect.disabled).toBe(false); + input.value = "second-token"; + input.dispatchEvent(new Event("input")); + connect.click(); + + expect((await booted)?.baseUrl).toBe("https://compass.example:8443"); +}); describe("bootNativeClient — the boot gate", () => { test("renders the connecting state before the probe settles", async () => { diff --git a/apps/ui/src/boot-native.ts b/apps/ui/src/boot-native.ts index 99c3e4cbb..88016bdee 100644 --- a/apps/ui/src/boot-native.ts +++ b/apps/ui/src/boot-native.ts @@ -145,17 +145,27 @@ function awaitUserConnect( .then(async (result) => { if (result.ok) { root.replaceChildren(); - resolve( - await deps - .nativeConnectionProvider(shellServerUrl() ?? "") - .resolve(), - ); + try { + resolve( + await deps + .nativeConnectionProvider(shellServerUrl() ?? "") + .resolve(), + ); + } catch (reason) { + reject(reason); + } return; } paint(result); syncDisabled(); }) - .catch(reject); + .catch((reason: unknown) => { + const message = + reason instanceof Error ? reason.message : String(reason); + heading.textContent = "Could not connect"; + detail.textContent = `Try again. ${message}`; + button.disabled = false; + }); }); root.replaceChildren(screen); }); @@ -247,6 +257,49 @@ function awaitUserSetupConnect( ? "Use Choose CA certificate… above to select the server's CA, then try again." : copy.hint; }; + const finishWithConnection = async (serverUrl: string): Promise => { + signal?.removeEventListener("abort", onAbort); + root.replaceChildren(); + try { + finish(await deps.nativeConnectionProvider(serverUrl).resolve()); + } catch (reason) { + fail(reason); + } + }; + const handleConnectResult = async ( + result: ConnectResult, + ): Promise => { + shellCallInFlight = false; + if (result.ok && result.serverUrl) { + await finishWithConnection(result.serverUrl); + return; + } + if (signal?.aborted) { + root.replaceChildren(); + finish(undefined); + return; + } + if (result.ok) { + heading.textContent = "Could not connect"; + detail.textContent = + "The server did not return its normalized server URL. Try again."; + } else { + paintResult(result); + } + syncDisabled(); + }; + const handleConnectRejection = (reason: unknown): void => { + shellCallInFlight = false; + if (signal?.aborted) { + root.replaceChildren(); + finish(undefined); + return; + } + const message = reason instanceof Error ? reason.message : String(reason); + heading.textContent = "Could not connect"; + detail.textContent = `Try again. ${message}`; + syncDisabled(); + }; url.addEventListener("input", syncDisabled); token.addEventListener("input", syncDisabled); signal?.addEventListener("abort", onAbort, { once: true }); @@ -283,42 +336,8 @@ function awaitUserSetupConnect( shellCallInFlight = true; syncDisabled(); void deps.shellConnect(secret, server).then( - async (result) => { - shellCallInFlight = false; - if (result.ok) { - signal?.removeEventListener("abort", onAbort); - root.replaceChildren(); - try { - const connection = await deps - .nativeConnectionProvider(result.serverUrl ?? server.url) - .resolve(); - finish(connection); - } catch (reason) { - fail(reason); - } - return; - } - if (signal?.aborted) { - root.replaceChildren(); - finish(undefined); - return; - } - paintResult(result); - syncDisabled(); - }, - (reason: unknown) => { - shellCallInFlight = false; - if (signal?.aborted) { - root.replaceChildren(); - finish(undefined); - return; - } - const message = - reason instanceof Error ? reason.message : String(reason); - heading.textContent = "Could not connect"; - detail.textContent = `Try again. ${message}`; - syncDisabled(); - }, + (result) => handleConnectResult(result), + (reason: unknown) => handleConnectRejection(reason), ); }); root.replaceChildren(screen); diff --git a/apps/ui/src/boot-setup.test.ts b/apps/ui/src/boot-setup.test.ts index 7aad25a24..28f6e22ec 100644 --- a/apps/ui/src/boot-setup.test.ts +++ b/apps/ui/src/boot-setup.test.ts @@ -1,5 +1,5 @@ /// -import { beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { bootNativeClient, type NativeBootDeps } from "./boot-native"; import { bootSetup, type SetupBootDeps } from "./boot-setup"; import type { @@ -66,6 +66,10 @@ beforeEach(() => { }; }); +afterEach(() => { + window.__COMPASS_SERVER_URL__ = undefined; +}); + async function flush(): Promise { for (let i = 0; i < 8; i++) await Promise.resolve(); } @@ -296,6 +300,28 @@ describe("bootSetup", () => { expect(await booted).toBeUndefined(); expect(unsubscribeCalls).toBe(1); }); + test("a deferred embedded failure stays visible after shellState returns setup", async () => { + const pendingRead = Promise.withResolvers(); + let calls = 0; + deps.shellState = async () => { + calls++; + return calls === 2 ? pendingRead.promise : state; + }; + const pendingChoice = Promise.withResolvers(); + deps.chooseEmbedded = () => pendingChoice.promise; + void bootSetup(root, deps); + await flush(); + button("Run Compass on this computer").click(); + emitDecision(); + await flush(); + pendingChoice.resolve({ ok: false, message: "embedded retry message" }); + await flush(); + pendingRead.resolve({ mode: "setup", serverUrl: "" }); + await flush(); + + expect(root.textContent).toContain("embedded retry message"); + expect(root.textContent).toContain("Connect to a server"); + }); test("a state read completing during an embedded attempt defers until failure", async () => { const read = Promise.withResolvers(); @@ -443,6 +469,59 @@ describe("bootSetup", () => { "Compass is already set up. Quit and reopen it to change this.", ); }); + test("a failed state read during an active connect preserves feedback and queued decisions", async () => { + const { promise: read, reject: rejectRead } = + Promise.withResolvers(); + let calls = 0; + deps.shellState = async () => { + calls++; + if (calls === 1) return { mode: "setup", serverUrl: "" }; + if (calls === 2) return read; + return { mode: "reopen", serverUrl: "" }; + }; + const pending: Array<(result: ConnectResult) => void> = []; + const nativeDeps = connectDeps(pending); + const setupDeps: SetupBootDeps = { + ...deps, + bootNativeClient: (receivedRoot, entry, signal) => + bootNativeClient(receivedRoot, nativeDeps, entry, signal), + }; + const booted = bootSetup(root, setupDeps); + await flush(); + expect(root.textContent).toContain("Connect to a server"); + button("Connect to a server").click(); + await flush(); + const fields = root.querySelectorAll("input"); + const url = fields.item(0); + const token = fields.item(1); + if ( + !(url instanceof HTMLInputElement) || + !(token instanceof HTMLInputElement) + ) + throw new Error("missing setup fields"); + url.value = "https://sibling.example"; + token.value = "token"; + token.dispatchEvent(new Event("input")); + button("Connect").click(); + await flush(); + emitDecision(); + const settle = pending[0]; + if (!settle) throw new Error("no in-flight shellConnect"); + rejectRead(new Error("state read failed during connect")); + await flush(); + emitDecision(); + settle(connectResult({ kind: "invalid-url", message: "bad URL" })); + await flush(); + + expect(root.textContent).toContain("state read failed during connect"); + emitDecision(); + await flush(); + expect(calls).toBe(3); + expect(root.textContent).toContain( + "Compass is already set up. Quit and reopen it to change this.", + ); + expect(await booted).toBeUndefined(); + }); test("a non-ok in-flight connect returns undefined before the state reread", async () => { const pending: Array<(result: ConnectResult) => void> = []; diff --git a/apps/ui/src/boot-setup.ts b/apps/ui/src/boot-setup.ts index bb5ffb1c7..7407e9657 100644 --- a/apps/ui/src/boot-setup.ts +++ b/apps/ui/src/boot-setup.ts @@ -49,6 +49,7 @@ export async function bootSetup( let chooseInFlight = false; let stateReadInFlight = false; let decisionQueued = false; + let queuedStateMessages: string[] = []; let connectController: AbortController | undefined; let resolveBoot: | ((value: ResolvedConnection | undefined) => void) @@ -77,9 +78,21 @@ export async function bootSetup( }; let choose: () => Promise; let connect: () => void; + const queueDecision = (message = ""): void => { + decisionQueued = true; + if (message.length > 0 && !queuedStateMessages.includes(message)) + queuedStateMessages.push(message); + }; + const takeQueuedStateMessage = (fallback = ""): string => { + if (fallback.length > 0 && !queuedStateMessages.includes(fallback)) + queuedStateMessages.push(fallback); + const message = queuedStateMessages.join("\n"); + queuedStateMessages = []; + return message; + }; const deferStateRead = (): boolean => { if (!stateReadInFlight && !chooseInFlight) return false; - decisionQueued = true; + queueDecision(); return true; }; const applyShellState = async ( @@ -88,7 +101,7 @@ export async function bootSetup( ): Promise => { if (terminal) return; if (chooseInFlight || connectController) { - decisionQueued = true; + queueDecision(message); if ( connectController && (current.mode === "client" || current.mode === "reopen") @@ -123,33 +136,62 @@ export async function bootSetup( } }; const handleShellStateError = (reason: unknown): void => { - if (terminal) rejectBoot?.(reason); - else if (!chooseInFlight && !connectController) - renderChoices(root, choose, connect, errorMessage(reason)); + if (terminal) { + rejectBoot?.(reason); + return; + } + if (chooseInFlight || connectController) { + queueDecision(errorMessage(reason)); + return; + } + decisionQueued = false; + renderChoices( + root, + choose, + connect, + takeQueuedStateMessage(errorMessage(reason)), + ); }; const finishShellStateRead = (): void => { stateReadInFlight = false; if (!decisionQueued || terminal || chooseInFlight || connectController) return; decisionQueued = false; - void readShellState(); + const message = takeQueuedStateMessage(); + void readShellState(message); + }; + const deferActiveShellState = ( + current: ShellState, + message: string, + ): boolean => { + if (!chooseInFlight && !connectController) return false; + queueDecision(message); + if ( + connectController && + (current.mode === "client" || current.mode === "reopen") + ) + connectController.abort(); + return true; }; const readShellState = async (message = ""): Promise => { - if (terminal || deferStateRead()) return; + if (terminal) return; + if (deferStateRead()) { + queueDecision(message); + return; + } stateReadInFlight = true; try { const current = await deps.shellState(); - if (chooseInFlight || connectController) { - decisionQueued = true; - if ( - connectController && - (current.mode === "client" || current.mode === "reopen") - ) - connectController.abort(); - } else { - await applyShellState(current, message); + stateReadInFlight = false; + if (deferActiveShellState(current, message)) return; + const displayMessage = takeQueuedStateMessage(message); + if (current.mode === "reopen") { + showReopen(); + return; } + await applyShellState(current, displayMessage); } catch (reason) { + stateReadInFlight = false; handleShellStateError(reason); } finally { finishShellStateRead(); @@ -185,7 +227,8 @@ export async function bootSetup( connectController = undefined; if (decisionQueued) { decisionQueued = false; - void readShellState(); + const message = takeQueuedStateMessage(); + void readShellState(message); } } }, @@ -195,7 +238,8 @@ export async function bootSetup( renderChoices(root, choose, connect, errorMessage(reason)); if (decisionQueued) { decisionQueued = false; - void readShellState(); + const message = takeQueuedStateMessage(errorMessage(reason)); + void readShellState(message); } }, ); @@ -212,7 +256,8 @@ export async function bootSetup( if (terminal) return; if (decisionQueued) { decisionQueued = false; - await readShellState(errorMessage(reason)); + const message = takeQueuedStateMessage(errorMessage(reason)); + await readShellState(message); return; } renderChoices(root, choose, connect, errorMessage(reason)); @@ -231,7 +276,8 @@ export async function bootSetup( } if (decisionQueued) { decisionQueued = false; - await readShellState(result.message); + const message = takeQueuedStateMessage(result.message); + await readShellState(message); return; } if (result.message === REOPEN_MESSAGE) { diff --git a/apps/ui/src/daemon-transport.ts b/apps/ui/src/daemon-transport.ts index 250ab9bd2..3da9dec0b 100644 --- a/apps/ui/src/daemon-transport.ts +++ b/apps/ui/src/daemon-transport.ts @@ -5,11 +5,12 @@ // Mirrors the Rust `ResponseFrame` (bridge.rs): a tagged head/body/end/error // stream. Body chunks are base64 so they ride the JSON channel as strings. +// Optional wire fields are omitted by Go when empty. export type ResponseFrame = - | { kind: "head"; status: number; headers: [string, string][] } - | { kind: "body"; chunk: string } + | { kind: "head"; status: number; headers?: [string, string][] } + | { kind: "body"; chunk?: string } | { kind: "end" } - | { kind: "error"; message: string }; + | { kind: "error"; message?: string }; /** * The shell↔UI frame seam (design §A2). A `ShellIpc` proxies a single gRPC-Web @@ -144,13 +145,13 @@ export function createDaemonFetch(ipc: ShellIpc): DaemonFetch { break; } case "body": - controller?.enqueue(decodeChunk(frame.chunk)); + controller?.enqueue(decodeChunk(frame.chunk ?? "")); break; case "end": controller?.close(); break; case "error": { - const err = new Error(frame.message); + const err = new Error(frame.message ?? "Shell RPC failed"); // Before the head arrives the failure rejects `fetch`; after, it // surfaces as a stream error the transport maps to a call failure. if (headSeen) controller?.error(err); @@ -207,7 +208,14 @@ export function wailsShellIpc(): ShellIpc { }; off = Events.On(eventName, (event: Events.WailsEvent) => { const frame: unknown = event.data; - if (!isResponseFrame(frame)) return; + if (!isResponseFrame(frame)) { + onFrame({ + kind: "error", + message: "Invalid response frame from shell", + }); + unsubscribe(); + return; + } onFrame(frame); if (frame.kind === "end" || frame.kind === "error") unsubscribe(); }); @@ -345,20 +353,36 @@ function isResponseFrame(value: unknown): value is ResponseFrame { if (value === null || typeof value !== "object" || !("kind" in value)) return false; switch (value.kind) { - case "head": + case "head": { + if ( + !("status" in value) || + typeof value.status !== "number" || + !Number.isInteger(value.status) || + value.status < 200 || + value.status > 599 + ) + return false; return ( - "status" in value && - typeof value.status === "number" && - "headers" in value && - Array.isArray(value.headers) && - value.headers.every((header) => isHeaderPair(header)) + !("headers" in value) || + value.headers === undefined || + (Array.isArray(value.headers) && + value.headers.every((header) => isHeaderPair(header))) ); + } case "body": - return "chunk" in value && typeof value.chunk === "string"; + return ( + !("chunk" in value) || + value.chunk === undefined || + typeof value.chunk === "string" + ); case "end": return true; case "error": - return "message" in value && typeof value.message === "string"; + return ( + !("message" in value) || + value.message === undefined || + typeof value.message === "string" + ); default: return false; } diff --git a/apps/ui/src/daemon-transport.wails.test.ts b/apps/ui/src/daemon-transport.wails.test.ts index 09fa2c4d2..926b8a363 100644 --- a/apps/ui/src/daemon-transport.wails.test.ts +++ b/apps/ui/src/daemon-transport.wails.test.ts @@ -1,29 +1,21 @@ /// // Contracts defended here (the Wails binding of the shell IPC seam, // daemon-transport.ts): -// - wailsShellIpc().rpc subscribes to the per-request runtime event -// "compass_rpc:"+requestId BEFORE invoking the bound CompassRPC method, and -// delivers each ResponseFrame to onFrame in the order the runtime emits them. -// - it unsubscribes on the terminal frame (end / error): a frame pushed after -// the terminal one never reaches onFrame. -// - rpc invokes CompassRPC by name with the exact {requestId,path,headers,body} -// args; cancel invokes CompassRPCCancel by name with the requestId. -// - nativeConnectionProvider().resolve() yields token === undefined (DL-109: -// the UI-side Connection never carries a bearer in client mode) and a defined -// fetchImpl. -// - shellConnect sends the optional server choice and maps the Go ConnectResult, -// which may omit serverUrl until the shell support for it lands. +// - rpc subscribes before calling the shell, preserves frame order, and removes +// its listener after a terminal frame. +// - Invalid runtime frames reach the transport as an error before unsubscription. +// - Shell calls keep the established binding names and request arguments. +// - Client provider connections never carry a bearer token (DL-109). +// - shellConnect maps current Go results, including those without serverUrl. // -// The Wails runtime is a hand-installed fake via mock.module: Events.On records -// each subscription and hands back an unsubscribe that flips a flag, and -// Call.ByName records every (method, args) and is driven by the test — exactly -// the fake-the-seam style of FakeShellIpc (daemon-transport.test.ts). No live -// Wails app, no Go process, no webview. - +// The Wails runtime is a hand-installed fake via mock.module. Events.On records +// subscriptions and returns an unsubscribe function for each event. +// Call.ByName records invocations that each test settles. import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import * as realRuntime from "@wailsio/runtime"; import { chooseEmbedded, + createDaemonFetch, nativeConnectionProvider, onSetupDecided, pickCACert, @@ -115,6 +107,9 @@ function emit(name: string, data: unknown): void { } } +async function flushMicrotasks(): Promise { + for (let i = 0; i < 8; i++) await Promise.resolve(); +} describe("wailsShellIpc", () => { const rpcArgs = { requestId: "req-1", @@ -141,14 +136,141 @@ describe("wailsShellIpc", () => { emit("compass_rpc:req-1", { kind: "end" }); expect(seen).toEqual(["head", "body", "end"]); }); + test("accepts a head frame with omitted headers", async () => { + const fetched = createDaemonFetch(wailsShellIpc())( + "https://daemon.invalid/compass.v1.CompassService/GetDaemonInfo", + ); + await flushMicrotasks(); + const sub = subscriptions[0]; + if (!sub) throw new Error("response subscription is missing"); + emit(sub.name, { kind: "head", status: 200 }); + emit(sub.name, { kind: "end" }); + + expect((await fetched).status).toBe(200); + expect(sub.off).toBe(true); + }); + test("accepts a body frame with omitted chunk as empty bytes", async () => { + const fetched = createDaemonFetch(wailsShellIpc())( + "https://daemon.invalid/compass.v1.CompassService/GetDaemonInfo", + ); + await flushMicrotasks(); + const sub = subscriptions[0]; + if (!sub) throw new Error("response subscription is missing"); + emit(sub.name, { kind: "head", status: 200 }); + const response = await fetched; + emit(sub.name, { kind: "body" }); + emit(sub.name, { kind: "end" }); + + expect((await response.arrayBuffer()).byteLength).toBe(0); + expect(sub.off).toBe(true); + }); - test("does not deliver a malformed runtime frame to the transport", () => { + test("invalid head frames reject fetch and unsubscribe", async () => { + for (const status of [700, 200.5]) { + installFakeRuntime(); + const fetched = createDaemonFetch(wailsShellIpc())( + "https://daemon.invalid/compass.v1.CompassService/GetDaemonInfo", + ); + await flushMicrotasks(); + const sub = subscriptions[0]; + if (!sub) throw new Error("response subscription is missing"); + emit(sub.name, { kind: "head", status, headers: [] }); + + await expect(fetched).rejects.toThrow( + "Invalid response frame from shell", + ); + expect(sub.off).toBe(true); + } + }); + + test("a malformed head rejects fetch and unsubscribes", async () => { const ipc = wailsShellIpc(); - const seen: string[] = []; - void ipc.rpc(rpcArgs, (frame) => seen.push(frame.kind)); + const fetched = createDaemonFetch(ipc)( + "https://daemon.invalid/compass.v1.CompassService/GetDaemonInfo", + ); + await flushMicrotasks(); + const sub = subscriptions[0]; + if (!sub) throw new Error("response subscription is missing"); + emit(sub.name, { kind: "head" }); + + await expect(fetched).rejects.toThrow("Invalid response frame from shell"); + expect(sub.off).toBe(true); + }); + test("a body frame with a non-string chunk rejects the active response", async () => { + const fetched = createDaemonFetch(wailsShellIpc())( + "https://daemon.invalid/compass.v1.CompassService/GetDaemonInfo", + ); + await flushMicrotasks(); + const sub = subscriptions[0]; + if (!sub) throw new Error("response subscription is missing"); + emit(sub.name, { kind: "head", status: 200 }); + const response = await fetched; + emit(sub.name, { kind: "body", chunk: 42 }); + + await expect(response.arrayBuffer()).rejects.toThrow( + "Invalid response frame from shell", + ); + expect(sub.off).toBe(true); + }); + test("a malformed terminal frame rejects the active response and unsubscribes", async () => { + const fetched = createDaemonFetch(wailsShellIpc())( + "https://daemon.invalid/compass.v1.CompassService/GetDaemonInfo", + ); + await flushMicrotasks(); + const sub = subscriptions[0]; + if (!sub) throw new Error("response subscription is missing"); + emit(sub.name, { kind: "head", status: 200 }); + const response = await fetched; + emit(sub.name, { kind: "terminal" }); + + await expect(response.arrayBuffer()).rejects.toThrow( + "Invalid response frame from shell", + ); + expect(sub.off).toBe(true); + }); + test("an error frame with omitted message uses the default failure text", async () => { + const fetched = createDaemonFetch(wailsShellIpc())( + "https://daemon.invalid/compass.v1.CompassService/GetDaemonInfo", + ); + await flushMicrotasks(); + const sub = subscriptions[0]; + if (!sub) throw new Error("response subscription is missing"); + emit(sub.name, { kind: "head", status: 200 }); + const response = await fetched; + emit(sub.name, { kind: "error" }); + + await expect(response.arrayBuffer()).rejects.toThrow("Shell RPC failed"); + expect(sub.off).toBe(true); + }); + test("an error frame with a non-string message is rejected", async () => { + const fetched = createDaemonFetch(wailsShellIpc())( + "https://daemon.invalid/compass.v1.CompassService/GetDaemonInfo", + ); + await flushMicrotasks(); + const sub = subscriptions[0]; + if (!sub) throw new Error("response subscription is missing"); + emit(sub.name, { kind: "head", status: 200 }); + const response = await fetched; + emit(sub.name, { kind: "error", message: 42 }); + + await expect(response.arrayBuffer()).rejects.toThrow( + "Invalid response frame from shell", + ); + expect(sub.off).toBe(true); + }); + test("error frames with explicit messages fail the active response", async () => { + const fetched = createDaemonFetch(wailsShellIpc())( + "https://daemon.invalid/compass.v1.CompassService/GetDaemonInfo", + ); + await flushMicrotasks(); + const sub = subscriptions[0]; + if (!sub) throw new Error("response subscription is missing"); + emit(sub.name, { kind: "head", status: 200 }); + const response = await fetched; + emit(sub.name, { kind: "error", message: "daemon error" }); - emit("compass_rpc:req-1", { kind: "body", chunk: 42 }); - expect(seen).toEqual([]); + await expect(response.arrayBuffer()).rejects.toThrow("daemon error"); + expect(sub.off).toBe(true); }); test("unsubscribes on the terminal end frame — a later frame never reaches onFrame", async () => { From c7247c1caa79204439878f0049d88fd15ef5a809 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 6 Oct 2026 02:30:26 -0400 Subject: [PATCH 4/4] fix(ui): retry decisions after failed state reads; allow null-body heads A sibling decision that arrives during a state read which then fails is retried with the error kept on screen. A decision queued by a stale read also aborts an idle connect form. A 101/103/204/205/304 head builds a Response with no body, which the constructor otherwise rejects. Refs RIG-4564 Co-authored-by: Matt Wilkinson --- apps/ui/src/boot-setup.test.ts | 40 ++++++++++++++++++++++++++++ apps/ui/src/boot-setup.ts | 15 ++++++----- apps/ui/src/daemon-transport.test.ts | 12 +++++++++ apps/ui/src/daemon-transport.ts | 21 ++++++++++----- 4 files changed, 75 insertions(+), 13 deletions(-) diff --git a/apps/ui/src/boot-setup.test.ts b/apps/ui/src/boot-setup.test.ts index 28f6e22ec..aeb544444 100644 --- a/apps/ui/src/boot-setup.test.ts +++ b/apps/ui/src/boot-setup.test.ts @@ -225,6 +225,46 @@ describe("bootSetup", () => { await flush(); expect(nativeCalls.map((call) => call.entry)).toEqual(["setup"]); }); + test("a decision during a state read that then fails is retried, keeping the error", async () => { + const first = Promise.withResolvers(); + let calls = 0; + deps.shellState = () => { + calls++; + if (calls === 1) return Promise.resolve({ mode: "setup", serverUrl: "" }); + if (calls === 2) return first.promise; + return Promise.resolve({ mode: "reopen", serverUrl: "" }); + }; + const booted = bootSetup(root, deps); + await flush(); + emitDecision(); + await flush(); + emitDecision(); + first.reject(new Error("state unavailable")); + await flush(); + expect(calls).toBe(3); + expect(await booted).toBeUndefined(); + expect(root.textContent).toContain("already set up"); + }); + test("a decision queued by a stale state read aborts the idle connect form", async () => { + const read = Promise.withResolvers(); + let calls = 0; + deps.shellState = () => { + calls++; + if (calls === 2) return read.promise; + return Promise.resolve({ mode: "setup", serverUrl: "" }); + }; + void bootSetup(root, deps); + await flush(); + emitDecision(); + await flush(); + button("Connect to a server").click(); + await flush(); + const signal = nativeCalls[0]?.signal; + expect(signal?.aborted).toBe(false); + read.resolve({ mode: "setup", serverUrl: "" }); + await flush(); + expect(signal?.aborted).toBe(true); + }); test("a rejected embedded choice restores both choices", async () => { deps.chooseEmbedded = async () => { throw new Error("preflight unavailable"); diff --git a/apps/ui/src/boot-setup.ts b/apps/ui/src/boot-setup.ts index 7407e9657..735f70262 100644 --- a/apps/ui/src/boot-setup.ts +++ b/apps/ui/src/boot-setup.ts @@ -78,8 +78,11 @@ export async function bootSetup( }; let choose: () => Promise; let connect: () => void; + // A queued decision always cancels an idle connect form; the form's own + // abort handling lets an in-flight call finish first. const queueDecision = (message = ""): void => { decisionQueued = true; + connectController?.abort(); if (message.length > 0 && !queuedStateMessages.includes(message)) queuedStateMessages.push(message); }; @@ -144,13 +147,11 @@ export async function bootSetup( queueDecision(errorMessage(reason)); return; } - decisionQueued = false; - renderChoices( - root, - choose, - connect, - takeQueuedStateMessage(errorMessage(reason)), - ); + const message = takeQueuedStateMessage(errorMessage(reason)); + renderChoices(root, choose, connect, message); + // A decision that arrived during the failed read still needs a read; + // keep the error visible by carrying it into the retry. + if (decisionQueued) queueDecision(message); }; const finishShellStateRead = (): void => { stateReadInFlight = false; diff --git a/apps/ui/src/daemon-transport.test.ts b/apps/ui/src/daemon-transport.test.ts index e2b44dfda..c156a431d 100644 --- a/apps/ui/src/daemon-transport.test.ts +++ b/apps/ui/src/daemon-transport.test.ts @@ -131,6 +131,18 @@ describe("createDaemonFetch", () => { expect(await readAllBytes(response)).toEqual([9, 8, 7]); }); + test("a 204 head resolves a Response with a null body", async () => { + const fetched = createDaemonFetch(ipc)( + "https://daemon.invalid/compass.v1.CompassService/GetDaemonInfo", + ); + const { onFrame } = await ipc.rpcMade; + onFrame({ kind: "head", status: 204, headers: [] }); + onFrame({ kind: "end" }); + const response = await fetched; + expect(response.status).toBe(204); + expect(response.body).toBeNull(); + }); + test("multi-frame stream: head + multiple body frames + end yield each decoded chunk in order", async () => { const daemonFetch = createDaemonFetch(ipc); const fetched = daemonFetch( diff --git a/apps/ui/src/daemon-transport.ts b/apps/ui/src/daemon-transport.ts index 3da9dec0b..89fda7a1d 100644 --- a/apps/ui/src/daemon-transport.ts +++ b/apps/ui/src/daemon-transport.ts @@ -32,6 +32,20 @@ export interface ShellIpc { cancel(requestId: string): void; } +// Statuses the Fetch spec forbids a body on. +const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); + +/** Build the fetch Response for a head frame. Response rejects a body on + * null-body statuses; later frames then drain into the unread stream. */ +function headResponse( + stream: ReadableStream, + status: number, + headers: [string, string][] | undefined, +): Response { + const body = NULL_BODY_STATUSES.has(status) ? null : stream; + return new Response(body, { status, headers: new Headers(headers) }); +} + /** Decode a standard-base64 body chunk to bytes for the response stream. */ function decodeChunk(b64: string): Uint8Array { const bin = atob(b64); @@ -136,12 +150,7 @@ export function createDaemonFetch(ipc: ShellIpc): DaemonFetch { switch (frame.kind) { case "head": { headSeen = true; - resolveHead( - new Response(stream, { - status: frame.status, - headers: new Headers(frame.headers), - }), - ); + resolveHead(headResponse(stream, frame.status, frame.headers)); break; } case "body":