diff --git a/apps/ui/src/boot-mode.test.ts b/apps/ui/src/boot-mode.test.ts index 7ebdbba9b..62952df11 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,24 +19,32 @@ function provider(connection: ResolvedConnection): ConnectionProvider { describe("bootForMode", () => { let root: HTMLElement; + let setupCalls: number; let clientCalls: number; let embeddedFactoryCalls: number; let browserCalls: number; let connectionBootCalls: number; + let quitCalls: number; let deps: BootModeDeps; - beforeEach(() => { root = document.createElement("div"); + setupCalls = 0; clientCalls = 0; embeddedFactoryCalls = 0; browserCalls = 0; connectionBootCalls = 0; + quitCalls = 0; deps = { bootNativeClient: async (receivedRoot) => { expect(receivedRoot).toBe(root); clientCalls++; return CONNECTION; }, + bootSetup: async (receivedRoot) => { + expect(receivedRoot).toBe(root); + setupCalls++; + return CONNECTION; + }, embeddedConnectionProvider: () => { embeddedFactoryCalls++; return provider(CONNECTION); @@ -50,6 +59,9 @@ describe("bootForMode", () => { connectionBootCalls++; return resolve(); }, + quitApp: async () => { + quitCalls++; + }, }; }); @@ -83,22 +95,43 @@ 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 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); + }); }); 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..24353de28 100644 --- a/apps/ui/src/boot-mode.ts +++ b/apps/ui/src/boot-mode.ts @@ -1,39 +1,37 @@ import { bootConnection } from "./boot"; import { bootBrowser } from "./boot-browser"; import { bootNativeClient } from "./boot-native"; -import { nativeConnectionProvider } from "./daemon-transport"; +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"; -/** 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: ( root: HTMLElement, resolve: () => Promise, ) => Promise; + quitApp: () => Promise; }; - 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, + // The daemon fetch constructs Requests from this base, so it must be absolute. embeddedConnectionProvider: () => nativeConnectionProvider(shellServerUrl() ?? "http://compass.localhost"), bootBrowser, bootConnection, + quitApp, }; -/** Select the runtime boot thunk for the shell-injected launch mode. */ export function bootForMode( mode: BootMode, root: HTMLElement, @@ -47,7 +45,18 @@ export function bootForMode( deps.bootConnection(root, () => deps.embeddedConnectionProvider().resolve(), ); - default: + case "setup": + return () => deps.bootSetup(root); + case "reopen": + return async () => { + renderReopenScreen(root, deps.quitApp); + 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..d8d014732 100644 --- a/apps/ui/src/boot-native.test.ts +++ b/apps/ui/src/boot-native.test.ts @@ -1,36 +1,16 @@ /// 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. +// Native boot tests drive shell calls through injected dependencies. +// The fake records choices and lets pending calls settle or reject. 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 pendingReject: Array<(reason: unknown) => void>; +let pickedCA: PickedCA; let deps: NativeBootDeps; function connectResult(over: Partial): ConnectResult { @@ -41,6 +21,7 @@ function connectResult(over: Partial): ConnectResult { accountId: "", serverVersion: "", apiVersion: "", + serverUrl: "https://compass.example:8443", ...over, }; } @@ -53,14 +34,24 @@ const NATIVE_CONNECTION: ResolvedConnection = { beforeEach(() => { connectTokens = []; + serverChoices = []; pending = []; + pendingReject = []; + pickedCA = { ref: "", name: "" }; deps = { - shellConnect: (token: string): Promise => { + shellConnect: ( + token: string, + server?: ServerChoice, + ): Promise => { connectTokens.push(token); - const { promise, resolve } = Promise.withResolvers(); + serverChoices.push(server); + const { promise, resolve, reject } = + Promise.withResolvers(); pending.push(resolve); + pendingReject.push(reject); return promise; }, + pickCACert: async (): Promise => pickedCA, nativeConnectionProvider: (baseUrl: string): ConnectionProvider => ({ async resolve(): Promise { return { ...NATIVE_CONNECTION, baseUrl }; @@ -69,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]; @@ -82,15 +68,343 @@ 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 rejects success without serverUrl and retries with the returned URL", 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/path"; + 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, { + ok: true, + kind: "", + message: "", + accountId: "", + serverVersion: "", + apiVersion: "", + }); + 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); +}); + +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"); +}); +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 () => { const root = document.createElement("div"); @@ -169,7 +483,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 — @@ -187,8 +503,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")); @@ -211,8 +531,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(); @@ -234,8 +558,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(); @@ -247,3 +575,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..88016bdee 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": @@ -46,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 { @@ -58,131 +55,293 @@ 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 { + if (entry === "setup") return awaitUserSetupConnect(root, deps, signal); renderConnecting(root); - - // 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. const probe = await deps.shellConnect(""); if (probe.ok) { + root.replaceChildren(); 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, deps: NativeBootDeps, ): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { 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(), - ); - 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); + void deps + .shellConnect(token) + .then(async (result) => { + if (result.ok) { + root.replaceChildren(); + try { + resolve( + await deps + .nativeConnectionProvider(shellServerUrl() ?? "") + .resolve(), + ); + } catch (reason) { + reject(reason); + } + return; + } + paint(result); + syncDisabled(); + }) + .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); + }); +} + +function awaitUserSetupConnect( + root: HTMLElement, + deps: NativeBootDeps, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) { + root.replaceChildren(); + return Promise.resolve(undefined); + } + return new Promise((resolve, reject) => { + 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 fail = (reason: unknown): void => { + if (finished) return; + finished = true; + signal?.removeEventListener("abort", onAbort); + reject(reason); + }; + 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); + 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; + }; + 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 }); + chooseCA.addEventListener("click", () => { + 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 = ""; + 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) => handleConnectResult(result), + (reason: unknown) => handleConnectRejection(reason), + ); + }); 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..aeb544444 --- /dev/null +++ b/apps/ui/src/boot-setup.test.ts @@ -0,0 +1,657 @@ +/// +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 { + ConnectResult, + SetupResult, + ShellState, +} 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; + }, + }; +}); + +afterEach(() => { + window.__COMPASS_SERVER_URL__ = undefined; +}); + +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 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 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"); + }; + 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 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(); + 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); + 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 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> = []; + 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..735f70262 --- /dev/null +++ b/apps/ui/src/boot-setup.ts @@ -0,0 +1,359 @@ +import { bootNativeClient } from "./boot-native"; +import { + BUTTON_STYLE, + DETAIL_STYLE, + HEADING_STYLE, + SCREEN_STYLE, +} from "./boot-styles"; +import { + chooseEmbedded, + onSetupDecided, + quitApp, + type SetupResult, + type ShellState, + shellState, +} from "./daemon-transport"; +import type { ResolvedConnection } from "./live/provider"; +import { type ShellMode, setShellServerUrl } from "./shell-globals"; + +export const REOPEN_MESSAGE = + "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 queuedStateMessages: string[] = []; + let connectController: AbortController | undefined; + let resolveBoot: + | ((value: ResolvedConnection | undefined) => void) + | undefined; + let rejectBoot: ((reason: unknown) => void) | undefined; + let unsubscribe: () => void = () => {}; + const booted = new Promise( + (resolve, reject) => { + resolveBoot = resolve; + rejectBoot = reject; + }, + ); + const closeListener = (): void => unsubscribe(); + const showReopen = (): void => { + terminal = true; + connectController?.abort(); + closeListener(); + 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; + // 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); + }; + 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; + queueDecision(); + return true; + }; + const applyShellState = async ( + current: ShellState, + message: string, + ): Promise => { + if (terminal) return; + if (chooseInFlight || connectController) { + queueDecision(message); + if ( + connectController && + (current.mode === "client" || current.mode === "reopen") + ) + connectController.abort(); + return; + } + switch (current.mode) { + case "client": { + terminal = true; + closeListener(); + setShellServerUrl(current.serverUrl); + try { + const connection = await deps.bootNativeClient(root, "configured"); + resolveBoot?.(connection); + } catch (reason) { + rejectBoot?.(reason); + } + return; + } + 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 handleShellStateError = (reason: unknown): void => { + if (terminal) { + rejectBoot?.(reason); + return; + } + if (chooseInFlight || connectController) { + queueDecision(errorMessage(reason)); + return; + } + 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; + if (!decisionQueued || terminal || chooseInFlight || connectController) + return; + decisionQueued = false; + 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) return; + if (deferStateRead()) { + queueDecision(message); + return; + } + stateReadInFlight = true; + try { + const current = await deps.shellState(); + 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(); + } + }; + 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(); + const setupConnection = deps.bootNativeClient( + root, + "setup", + connectController.signal, + ); + void setupConnection.then( + (connection) => { + if (terminal) return; + if (connection) { + connectController = undefined; + terminal = true; + closeListener(); + resolveBoot?.(connection); + } else { + connectController = undefined; + if (decisionQueued) { + decisionQueued = false; + const message = takeQueuedStateMessage(); + void readShellState(message); + } + } + }, + (reason: unknown) => { + if (terminal) return; + connectController = undefined; + renderChoices(root, choose, connect, errorMessage(reason)); + if (decisionQueued) { + decisionQueued = false; + const message = takeQueuedStateMessage(errorMessage(reason)); + void readShellState(message); + } + }, + ); + }; + const handleEmbeddedChoice = async (): Promise => { + if (terminal || chooseInFlight) return; + chooseInFlight = true; + renderChecking(root); + let result: SetupResult; + try { + result = await deps.chooseEmbedded(); + } catch (reason) { + chooseInFlight = false; + if (terminal) return; + if (decisionQueued) { + decisionQueued = false; + const message = takeQueuedStateMessage(errorMessage(reason)); + await readShellState(message); + 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); + return; + } + if (decisionQueued) { + decisionQueued = false; + const message = takeQueuedStateMessage(result.message); + await readShellState(message); + return; + } + 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, + 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.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 5c398e023..89fda7a1d 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 @@ -31,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); @@ -39,12 +54,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 @@ -57,7 +70,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; @@ -134,22 +150,17 @@ 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": - 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); @@ -173,7 +184,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 +193,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 @@ -202,7 +216,15 @@ 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)) { + onFrame({ + kind: "error", + message: "Invalid response frame from shell", + }); + unsubscribe(); + return; + } onFrame(frame); if (frame.kind === "end" || frame.kind === "error") unsubscribe(); }); @@ -220,7 +242,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(() => {}); }, }; @@ -238,21 +260,181 @@ 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) + ) + 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" && + (!("serverUrl" in value) || 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" + ); +} + +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": { + if ( + !("status" in value) || + typeof value.status !== "number" || + !Number.isInteger(value.status) || + value.status < 200 || + value.status > 599 + ) + return false; + return ( + !("headers" in value) || + value.headers === undefined || + (Array.isArray(value.headers) && + value.headers.every((header) => isHeaderPair(header))) + ); + } + case "body": + return ( + !("chunk" in value) || + value.chunk === undefined || + typeof value.chunk === "string" + ); + case "end": + return true; + case "error": + return ( + !("message" in value) || + value.message === undefined || + typeof value.message === "string" + ); + default: + return false; + } +} + +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 @@ -264,10 +446,11 @@ export function shellConnect(token: string): 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 82ae12f37..926b8a363 100644 --- a/apps/ui/src/daemon-transport.wails.test.ts +++ b/apps/ui/src/daemon-transport.wails.test.ts @@ -1,30 +1,27 @@ /// // 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(token) invokes the Connect method by name with the token and -// maps the returned ConnectResult through faithfully (ok and failure kinds). +// - 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, + quitApp, shellConnect, + shellState, wailsShellIpc, } from "./daemon-transport"; @@ -47,6 +44,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 +53,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 }; @@ -102,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", @@ -128,6 +136,142 @@ 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("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 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" }); + + 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 () => { const ipc = wailsShellIpc(); @@ -197,6 +341,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); @@ -206,6 +351,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({ @@ -215,6 +375,7 @@ describe("shellConnect", () => { accountId: "", serverVersion: "", apiVersion: "", + serverUrl: "", }); const result = await promise; expect(result.ok).toBe(false); @@ -222,3 +383,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..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; @@ -33,4 +33,15 @@ 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"); + }); + + 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 08eb2b054..3e5a709a6 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 { @@ -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; +}