diff --git a/packages/dashboard/app/components/DesktopLaunchGate.tsx b/packages/dashboard/app/components/DesktopLaunchGate.tsx index c5890b6285..5d4f6d8d14 100644 --- a/packages/dashboard/app/components/DesktopLaunchGate.tsx +++ b/packages/dashboard/app/components/DesktopLaunchGate.tsx @@ -1,7 +1,6 @@ import { useEffect, useState, type PropsWithChildren } from "react"; import { useTranslation } from "react-i18next"; import type { ShellConnectionState } from "../types/native-shell"; -import { getShellHostContext } from "../shell-host"; import "./DesktopLaunchGate.css"; type Phase = @@ -51,15 +50,20 @@ async function waitForLocalRuntime( throw new Error("Local runtime did not become ready in time"); } -function applyServerBaseUrl(baseUrl: string): void { - // Reload the page with the local runtime URL so shell-host bootstrap reads - // it and routes API calls through it (the page itself is loaded via file:// - // so relative /api fetches would otherwise fail). - const url = new URL(window.location.href); - url.searchParams.set("serverBaseUrl", baseUrl); - url.searchParams.set("shellKind", "desktop-shell"); - url.searchParams.set("shellMode", "local"); - window.location.replace(url.toString()); +function navigateToLocalRuntimeOrigin(baseUrl: string): void { + /* + * FNXC:DesktopLaunchGate 2026-07-03-02:10: + * Load the UI FROM the embedded runtime's own origin (http://127.0.0.1:/) rather than + * staying on the packaged file:// page. The dashboard client makes RELATIVE /api requests; on a + * file:// origin those resolve to file:///api/… and fail ("Can't reach the Fusion backend / Failed + * to fetch"), and the embedded server sends no CORS header so a cross-origin fetch would be blocked + * too. The embedded server also serves the client HTML at /, so navigating there makes /api + * same-origin and everything just works — this mirrors how remote mode navigates to its server URL. + */ + const target = new URL("/", baseUrl); + target.searchParams.set("shellKind", "desktop-shell"); + target.searchParams.set("shellMode", "local"); + window.location.replace(target.toString()); } export function DesktopLaunchGate({ children }: PropsWithChildren) { @@ -94,22 +98,17 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) { // active profile already. if (state.desktopMode === "local") { /* - * FNXC:DesktopLaunchGate 2026-07-03-01:05: - * Detect a completed local handoff via the CACHED shell-host context, NOT the raw URL. - * applyServerBaseUrl() reloads the page with ?serverBaseUrl=…, but main.tsx calls - * bootstrapShellHostContext() at module load (before this effect runs), which STRIPS every - * shell query param from the URL via history.replaceState. So reading window.location.search - * here always misses serverBaseUrl → we would re-run the handoff → window.location.replace → - * reload → strip → an INFINITE reload loop, seen as rapid "Starting local Fusion runtime…" - * flashing that never connects. The bootstrap preserves the captured value in - * getShellHostContext().serverUrl, so read that (fall back to any not-yet-stripped URL param). + * FNXC:DesktopLaunchGate 2026-07-03-02:10: + * If this page is already served over http(s), it's the embedded runtime serving the UI, + * so /api is same-origin — render the app. The packaged renderer first loads from file:// + * (where relative /api fetches fail); there we start the runtime and navigate to its origin + * (navigateToLocalRuntimeOrigin). Gating on the protocol rather than a serverBaseUrl URL + * param also avoids the prior reload loop — main.tsx's bootstrapShellHostContext() strips + * shell query params at module load, so a param-based "already handed off" check always + * missed and reloaded forever ("rapid Starting Fusion flashing"). */ - const shellHost = getShellHostContext(); - const handoffBaseUrl = shellHost.kind === "desktop-shell" ? shellHost.serverUrl : undefined; - const urlParamBaseUrl = new URLSearchParams(window.location.search).get("serverBaseUrl") ?? undefined; - const existingBaseUrl = handoffBaseUrl ?? urlParamBaseUrl; - if (existingBaseUrl) { - setPhase({ kind: "ready", serverBaseUrl: existingBaseUrl }); + if (window.location.protocol !== "file:") { + setPhase({ kind: "ready" }); return; } setPhase({ kind: "starting-local", message: t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") }); @@ -141,7 +140,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) { } const { baseUrl } = await waitForLocalRuntime(shell); if (cancelled) return; - applyServerBaseUrl(baseUrl); + navigateToLocalRuntimeOrigin(baseUrl); return; } diff --git a/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx b/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx index 79d959584c..03acf040dc 100644 --- a/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx +++ b/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx @@ -1,22 +1,29 @@ import { render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; // t() returns the provided fallback so we assert on stable English text. vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }), })); -const getShellHostContext = vi.fn(); -vi.mock("../../shell-host", () => ({ getShellHostContext: () => getShellHostContext() })); - import { DesktopLaunchGate } from "../DesktopLaunchGate"; -type LocationStub = { href: string; search: string; replace: ReturnType; reload: ReturnType }; +type LocationStub = { + protocol: string; + href: string; + search: string; + port: string; + replace: ReturnType; + reload: ReturnType; +}; -function stubLocation(search: string): LocationStub { +function stubLocation(href: string): LocationStub { + const u = new URL(href); const loc: LocationStub = { - href: `file:///C:/app/index.html${search}`, - search, + protocol: u.protocol, + href, + search: u.search, + port: u.port, replace: vi.fn(), reload: vi.fn(), }; @@ -35,51 +42,27 @@ function stubShell(state: unknown) { return shell; } +const localReadyState = { + host: "desktop-shell", + desktopMode: "local", + desktopModeState: { isFirstRun: false, desktopMode: "local" }, + localRuntime: { source: "embedded-local", state: "running", port: 50123, baseUrl: "http://127.0.0.1:50123" }, + profiles: [], + activeProfileId: null, +}; + describe("DesktopLaunchGate — local handoff", () => { - beforeEach(() => { - getShellHostContext.mockReset(); - }); afterEach(() => { delete (window as unknown as { fusionShell?: unknown }).fusionShell; }); /* - * Regression: after applyServerBaseUrl() reloads with ?serverBaseUrl=…, main.tsx's - * bootstrapShellHostContext() strips that param from the URL before this gate runs. The gate - * MUST recognize the completed handoff from the cached shell-host context (serverUrl), not the - * stripped URL — otherwise it re-triggers the handoff → window.location.replace → reload loop - * ("rapid Starting local Fusion runtime flashing that never connects"). + * Regression for "Can't reach the Fusion backend / Failed to fetch": on the packaged file:// page, + * relative /api requests fail, so the gate must load the UI from the embedded runtime's own origin. */ - it("renders children (no reload) when the handoff is present in the cached context but stripped from the URL", async () => { - const location = stubLocation(""); // URL already stripped by bootstrap - getShellHostContext.mockReturnValue({ kind: "desktop-shell", mode: "local", serverUrl: "http://127.0.0.1:50123" }); - stubShell({ - host: "desktop-shell", - desktopMode: "local", - desktopModeState: { isFirstRun: false, desktopMode: "local" }, - localRuntime: { source: "embedded-local", state: "running", port: 50123, baseUrl: "http://127.0.0.1:50123" }, - }); - - render( - -
app
-
, - ); - - await waitFor(() => expect(screen.getByTestId("app-loaded")).toBeTruthy()); - // The bug was an infinite reload; assert we never reload. - expect(location.replace).not.toHaveBeenCalled(); - }); - - it("performs the handoff exactly once on first load (no cached serverUrl yet)", async () => { - const location = stubLocation(""); // fresh launch, no shell params - getShellHostContext.mockReturnValue({ kind: "desktop-shell" }); // bootstrap saw no serverUrl - stubShell({ - host: "desktop-shell", - desktopMode: "local", - desktopModeState: { isFirstRun: false, desktopMode: "local" }, - localRuntime: { source: "embedded-local", state: "running", port: 50123, baseUrl: "http://127.0.0.1:50123" }, - }); + it("navigates to the runtime origin exactly once when loaded from file://", async () => { + const location = stubLocation("file:///C:/app/index.html"); + stubShell(localReadyState); render( @@ -88,6 +71,55 @@ describe("DesktopLaunchGate — local handoff", () => { ); await waitFor(() => expect(location.replace).toHaveBeenCalledTimes(1)); - expect(location.replace.mock.calls[0][0]).toContain("serverBaseUrl=http%3A%2F%2F127.0.0.1%3A50123"); + const target = location.replace.mock.calls[0][0] as string; + expect(target).toMatch(/^http:\/\/127\.0\.0\.1:50123\//); + expect(target).toContain("shellMode=local"); + }); + + /* + * Regression for the reload loop ("rapid Starting Fusion flashing"): once the page is served over + * http by the runtime, the gate must render the app and NOT navigate again. + */ + it("renders the app (no navigation) when already served over http by the runtime", async () => { + const location = stubLocation("http://127.0.0.1:50123/"); + const shell = stubShell(localReadyState); + + render( + +
app
+
, + ); + + await waitFor(() => expect(screen.getByTestId("app-loaded")).toBeTruthy()); + expect(location.replace).not.toHaveBeenCalled(); + expect(shell.setDesktopMode).not.toHaveBeenCalled(); + }); + + it("starts the runtime when it is not running, then navigates to its origin", async () => { + const location = stubLocation("file:///C:/app/index.html"); + // First getState: stopped. setDesktopMode starts it; subsequent polls: running. + let started = false; + const running = localReadyState; + const stopped = { ...localReadyState, localRuntime: { source: "none", state: "stopped" } }; + const shell = { + getState: vi.fn(async () => (started ? running : stopped)), + setDesktopMode: vi.fn(async () => { + started = true; + return running; + }), + onResetDesktopModeRequest: vi.fn(() => () => undefined), + resetDesktopMode: vi.fn(async () => undefined), + }; + (window as unknown as { fusionShell: unknown }).fusionShell = shell; + + render( + +
app
+
, + ); + + await waitFor(() => expect(shell.setDesktopMode).toHaveBeenCalledWith("local")); + await waitFor(() => expect(location.replace).toHaveBeenCalledTimes(1)); + expect(location.replace.mock.calls[0][0]).toMatch(/^http:\/\/127\.0\.0\.1:50123\//); }); });