diff --git a/packages/app/e2e/browser/settings-host-selection.spec.ts b/packages/app/e2e/browser/settings-host-selection.spec.ts new file mode 100644 index 000000000..aa92e5611 --- /dev/null +++ b/packages/app/e2e/browser/settings-host-selection.spec.ts @@ -0,0 +1,52 @@ +import { test } from "../support/fixtures"; +import { openSettings } from "../support/helpers/app"; +import { getE2EDaemonPort, wsRoutePatternForPort } from "../support/helpers/daemon-port"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { + addDirectHostFromSettings, + clickSettingsBackToWorkspace, + openSettingsHostSection, +} from "../support/helpers/settings"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; + +test.describe("Settings host selection", () => { + test.describe.configure({ timeout: 180_000 }); + + test("entering Settings from a remote workspace selects that remote host", async ({ page }) => { + const remoteDaemon = await startIsolatedHostDaemon("settings-host-selection-remote"); + const remoteWorkspace = await seedWorkspace({ + port: remoteDaemon.port, + repoPrefix: "settings-host-selection-remote-workspace-", + title: "Remote workspace", + }); + + try { + // The default local profile remains in the registry, but its daemon is offline. + await page.routeWebSocket(wsRoutePatternForPort(getE2EDaemonPort()), async (ws) => { + await ws.close({ code: 1008, reason: "The local daemon is disconnected." }); + }); + + await page.goto("/"); + await openSettings(page); + await addDirectHostFromSettings(page, { + host: "127.0.0.1", + port: remoteDaemon.port, + }); + + await clickSettingsBackToWorkspace(page); + await switchWorkspaceViaSidebar({ + page, + serverId: remoteDaemon.serverId, + workspaceId: remoteWorkspace.workspaceId, + }); + + await openSettings(page); + + await openSettingsHostSection(page, remoteDaemon.serverId, "connections"); + } finally { + await remoteWorkspace.cleanup().catch(() => undefined); + await remoteDaemon.close().catch(() => undefined); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 2f052dc9c..9a3a12512 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -86,6 +86,18 @@ export async function selectHostConnectionType( await page.getByRole("button", { name: label }).click(); } +export async function addDirectHostFromSettings( + page: Page, + input: { host: string; port: number }, +): Promise { + await openAddHostFlow(page); + await selectHostConnectionType(page, "direct"); + await page.getByTestId("direct-host-input").fill(input.host); + await page.getByTestId("direct-port-input").fill(String(input.port)); + await page.getByTestId("direct-host-submit").click(); + await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 }); +} + export async function toggleHostAdvanced(page: Page): Promise { await page.getByTestId("direct-host-advanced-toggle").click(); } diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index abe7aa04d..ed6a8ccbe 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -7,24 +7,25 @@ import { shouldRunStartupGiveUpTimer, startHostRuntimeBootstrap, } from "./host-runtime-bootstrap"; -import type { - DaemonStartCondition, - StartDaemonIfEnabledInput, -} from "@/runtime/daemon-start-service"; +import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service"; describe("startHostRuntimeBootstrap", () => { - it("boots the host registry and starts the managed-daemon decision as one operation", () => { + it("boots the host registry and starts the managed-daemon decision as one operation", async () => { const events: string[] = []; const shouldStartDaemon = async () => true; const store = { - boot: () => { + boot: async () => { events.push("boot"); }, }; - let receivedCondition: DaemonStartCondition | null = null; + const receivedDecisions: Array> = []; const daemonStartService = { startIfEnabled: async (input: StartDaemonIfEnabledInput) => { - receivedCondition = input.shouldStart; + receivedDecisions.push( + Promise.resolve( + typeof input.shouldStart === "boolean" ? input.shouldStart : input.shouldStart(), + ), + ); events.push("daemon-start-decision"); return { ok: true as const }; }, @@ -37,7 +38,55 @@ describe("startHostRuntimeBootstrap", () => { }); expect(events).toEqual(["boot", "daemon-start-decision"]); - expect(receivedCondition).toBe(shouldStartDaemon); + expect(await receivedDecisions[0]).toBe(true); + }); + + it("waits for the host registry to load before evaluating managed-daemon startup", async () => { + const events: string[] = []; + let resolveBoot!: () => void; + const booted = new Promise((resolve) => { + resolveBoot = resolve; + }); + const store = { + boot: () => { + events.push("boot"); + return booted; + }, + }; + let startFinished!: Promise; + const daemonStartService = { + startIfEnabled: (input: StartDaemonIfEnabledInput) => { + events.push("daemon-start-service"); + startFinished = (async () => { + const shouldStart = + typeof input.shouldStart === "boolean" ? input.shouldStart : await input.shouldStart(); + events.push(`decision:${shouldStart}`); + return { ok: true as const }; + })(); + return startFinished; + }, + }; + + startHostRuntimeBootstrap({ + store, + daemonStartService, + shouldStartDaemon: () => { + events.push("evaluate-daemon-setting"); + return true; + }, + }); + + await Promise.resolve(); + expect(events).toEqual(["boot", "daemon-start-service"]); + + resolveBoot(); + await startFinished; + expect(events).toEqual([ + "boot", + "daemon-start-service", + "evaluate-daemon-setting", + "decision:true", + ]); }); }); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index eb09a88db..6fbceb8a2 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -12,7 +12,7 @@ import { } from "@/utils/host-routes"; export interface HostRuntimeBootstrapStore { - boot: () => void; + boot: () => Promise; } export interface HostRuntimeBootstrapDaemonStartService { @@ -26,9 +26,14 @@ export interface StartHostRuntimeBootstrapInput { } export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void { - input.store.boot(); + const registryReady = input.store.boot(); void input.daemonStartService.startIfEnabled({ - shouldStart: input.shouldStartDaemon, + shouldStart: async () => { + await registryReady; + return typeof input.shouldStartDaemon === "boolean" + ? input.shouldStartDaemon + : input.shouldStartDaemon(); + }, }); } diff --git a/packages/app/src/runtime/daemon-start-service.test.ts b/packages/app/src/runtime/daemon-start-service.test.ts index e2c82ddf8..a988a09e0 100644 --- a/packages/app/src/runtime/daemon-start-service.test.ts +++ b/packages/app/src/runtime/daemon-start-service.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { DaemonStartService, upsertDesktopDaemonConnection } from "./daemon-start-service"; import type { HostRuntimeStore } from "./host-runtime"; import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; +import { defaultHostAppearance } from "@/hosts/appearance"; +import type { HostProfile } from "@/types/host-connection"; interface RecordedUpsert { listenAddress: string; @@ -9,12 +11,13 @@ interface RecordedUpsert { hostname: string | null; } -function createFakeStore(): { - store: Pick; +function createFakeStore(hosts: HostProfile[] = []): { + store: Pick; upserts: RecordedUpsert[]; } { const upserts: RecordedUpsert[] = []; const store = { + getHosts: () => hosts, upsertConnectionFromListen: async (input: RecordedUpsert) => { upserts.push(input); return {} as Awaited>; @@ -38,6 +41,26 @@ function makeStatus(overrides: Partial = {}): DesktopDaemon }; } +function makeRelayOnlyHost(serverId: string): HostProfile { + return { + serverId, + label: "Relay host", + appearance: defaultHostAppearance(), + lifecycle: {}, + connections: [ + { + id: "relay:relay.example.com", + type: "relay", + relayEndpoint: "relay.example.com", + daemonPublicKeyB64: "public-key", + }, + ], + preferredConnectionId: "relay:relay.example.com", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + describe("DaemonStartService", () => { it("upserts the connection on a successful daemon start", async () => { const fake = createFakeStore(); @@ -291,6 +314,15 @@ describe("upsertDesktopDaemonConnection", () => { ]); }); + it("does not add localhost when desktop bootstrap finds its server id already registered", async () => { + const fake = createFakeStore([makeRelayOnlyHost("srv_desktop")]); + + const result = await upsertDesktopDaemonConnection(fake.store, makeStatus()); + + expect(result).toEqual({ ok: true }); + expect(fake.upserts).toEqual([]); + }); + it("rejects a missing listen address without upserting", async () => { const fake = createFakeStore(); diff --git a/packages/app/src/runtime/daemon-start-service.ts b/packages/app/src/runtime/daemon-start-service.ts index 7a254c4e3..093158e9e 100644 --- a/packages/app/src/runtime/daemon-start-service.ts +++ b/packages/app/src/runtime/daemon-start-service.ts @@ -9,7 +9,7 @@ export interface StartDaemonIfEnabledInput { shouldStart: DaemonStartCondition; } -type DaemonConnectionStore = Pick; +type DaemonConnectionStore = Pick; export interface DaemonStartServiceDeps { store: DaemonConnectionStore; @@ -20,14 +20,18 @@ export async function upsertDesktopDaemonConnection( store: DaemonConnectionStore, daemon: DesktopDaemonStatus, ): Promise { - const listenAddress = daemon.listen?.trim() ?? ""; const serverId = daemon.serverId.trim(); - if (!listenAddress) { - return { ok: false, error: "Desktop daemon did not return a listen address." }; - } if (!serverId) { return { ok: false, error: "Desktop daemon did not return a server id." }; } + if (store.getHosts().some((host) => host.serverId === serverId)) { + return { ok: true }; + } + + const listenAddress = daemon.listen?.trim() ?? ""; + if (!listenAddress) { + return { ok: false, error: "Desktop daemon did not return a listen address." }; + } if (!connectionFromListen(listenAddress)) { return { ok: false, diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index c89bd9d0a..488ede2eb 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1358,7 +1358,7 @@ export class HostRuntimeStore { private queuedAgentDrainInFlight = new Set(); private directorySyncByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; - private bootStarted = false; + private bootPromise: Promise | null = null; private storage: HostRuntimeStorage; private replicaCache: ReplicaCache; @@ -1393,12 +1393,11 @@ export class HostRuntimeStore { return this.hostRegistryLoaded; } - boot(): void { - if (this.bootStarted) { - return; + boot(): Promise { + if (!this.bootPromise) { + this.bootPromise = this.runBoot(); } - this.bootStarted = true; - void this.runBoot(); + return this.bootPromise; } private async runBoot(): Promise { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 3ba244202..d73834b21 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -121,7 +121,10 @@ import { type HostSectionSlug, type SettingsSectionSlug, } from "@/utils/host-routes"; -import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store"; +import { + navigateToLastWorkspace, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; // --------------------------------------------------------------------------- // View model @@ -1140,14 +1143,19 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); + const lastWorkspaceSelection = useLastWorkspaceSelection(); + const routedSettingsHostServerId = + view.kind === "host" || view.kind === "project" ? view.serverId : null; const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( - view.kind === "host" || view.kind === "project" ? view.serverId : null, + routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, + ); + useFocusEffect( + useCallback(() => { + setSelectedSettingsHostServerId( + routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, + ); + }, [lastWorkspaceSelection?.serverId, routedSettingsHostServerId]), ); - useEffect(() => { - if (view.kind === "host" || view.kind === "project") { - setSelectedSettingsHostServerId(view.serverId); - } - }, [view]); // The host the four sections scope to: the host on the active view, otherwise // the picker choice, otherwise the connected local daemon, otherwise the first host.