fix(desktop): load local UI from the runtime origin so /api is reachable
After the runtime starts, the packaged renderer was left on its file:// page and only had
?serverBaseUrl=… appended. But the dashboard client issues RELATIVE /api requests, so on a
file:// origin they resolve to file:///api/… and fail ("Can't reach the Fusion backend /
Failed to fetch"); the embedded server also sends no CORS header, so a cross-origin fetch
would be blocked as well. (The server itself is fine — verified it serves both /api/health
and the client HTML at /.)
Fix: once the runtime is running, navigate the window to the runtime's OWN origin
(http://127.0.0.1:<port>/), which the embedded server serves — making /api same-origin.
This mirrors how remote mode already navigates to its server URL. The gate now treats "page
served over http(s)" as the ready signal (protocol check) instead of a serverBaseUrl URL
param; that also supersedes the previous cached-context handoff check (04fd91f6) and keeps
the reload loop closed, since bootstrapShellHostContext() strips shell query params at load.
Regression tests: navigates to the runtime origin exactly once from file://; renders the app
without navigating when already served over http; starts the runtime first when it isn't
running, then navigates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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:<port>/) 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof vi.fn>; reload: ReturnType<typeof vi.fn> };
|
||||
type LocationStub = {
|
||||
protocol: string;
|
||||
href: string;
|
||||
search: string;
|
||||
port: string;
|
||||
replace: ReturnType<typeof vi.fn>;
|
||||
reload: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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(
|
||||
<DesktopLaunchGate>
|
||||
<div data-testid="app-loaded">app</div>
|
||||
</DesktopLaunchGate>,
|
||||
);
|
||||
|
||||
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(
|
||||
<DesktopLaunchGate>
|
||||
@@ -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(
|
||||
<DesktopLaunchGate>
|
||||
<div data-testid="app-loaded">app</div>
|
||||
</DesktopLaunchGate>,
|
||||
);
|
||||
|
||||
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(
|
||||
<DesktopLaunchGate>
|
||||
<div data-testid="app-loaded">app</div>
|
||||
</DesktopLaunchGate>,
|
||||
);
|
||||
|
||||
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\//);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user