Files
fusion/packages/dashboard/app/components/DesktopLaunchGate.tsx
gsxdsm 0c43ed42cf fix: address PR review feedback + typecheck failure
- DesktopLaunchGate: the chooser onPick("local") path still called the removed
  applyServerBaseUrl helper (broke Typecheck and the desktop local chooser flow) —
  call navigateToLocalRuntimeOrigin. Also re-read shell.getState() immediately before the
  self-healing start check so a stale mount snapshot doesn't fire a redundant
  setDesktopMode("local") on normal boots (Greptile).
- register-fn-binary-routes: on Windows the npm install runs under a shell, so a timeout's
  child.kill only stopped cmd.exe and left npm.cmd/node running — kill the whole process tree
  via taskkill /T on win32 (CodeRabbit).
- local-server.test: align with the new resolver contract — assert the runtime never
  auto-registers the root project and starts engine-less when no projects exist (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:57:25 -07:00

293 lines
11 KiB
TypeScript

import { useEffect, useState, type PropsWithChildren } from "react";
import { useTranslation } from "react-i18next";
import type { ShellConnectionState } from "../types/native-shell";
import "./DesktopLaunchGate.css";
type Phase =
| { kind: "loading" }
| { kind: "chooser"; state: ShellConnectionState }
| { kind: "starting-local"; message: string }
| { kind: "local-error"; message: string }
| { kind: "ready"; serverBaseUrl?: string }
| { kind: "bypass" };
function getFusionShell() {
if (typeof window === "undefined") return null;
return window.fusionShell ?? null;
}
function isDesktopShell(state: ShellConnectionState | null): boolean {
return state?.host === "desktop-shell";
}
function needsChooser(state: ShellConnectionState): boolean {
const modeState = state.desktopModeState;
if (modeState) {
return modeState.isFirstRun || modeState.desktopMode === null;
}
return !state.desktopMode;
}
async function waitForLocalRuntime(
shell: NonNullable<ReturnType<typeof getFusionShell>>,
timeoutMs = 30_000,
): Promise<{ baseUrl: string }> {
const deadline = Date.now() + timeoutMs;
// The runtime is started by main when setDesktopMode("local") fires; just
// poll shell:getState until localRuntime reports running.
while (Date.now() < deadline) {
const state = await shell.getState();
const rt = state.localRuntime;
if (rt?.state === "running" && (rt.baseUrl || rt.port)) {
const baseUrl = rt.baseUrl ?? `http://127.0.0.1:${rt.port}`;
return { baseUrl };
}
if (rt?.state === "error") {
throw new Error(rt.error ?? "Local runtime failed to start");
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error("Local runtime did not become ready in time");
}
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) {
const { t } = useTranslation("app");
const [phase, setPhase] = useState<Phase>({ kind: "loading" });
useEffect(() => {
const shell = getFusionShell();
if (!shell) {
setPhase({ kind: "bypass" });
return;
}
let cancelled = false;
void (async () => {
try {
const state = await shell.getState();
if (cancelled) return;
if (!isDesktopShell(state)) {
setPhase({ kind: "bypass" });
return;
}
if (needsChooser(state)) {
setPhase({ kind: "chooser", state });
return;
}
// Mode already chosen. If local, ensure runtime URL is in the
// current page URL; if remote, App handles the redirect to the
// active profile already.
if (state.desktopMode === "local") {
/*
* 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").
*/
if (window.location.protocol !== "file:") {
setPhase({ kind: "ready" });
return;
}
setPhase({ kind: "starting-local", message: t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") });
/*
* FNXC:DesktopLaunchGate 2026-07-02-14:35:
* Self-healing start. Do NOT assume main already started the embedded runtime.
* The gate decides to WAIT from shell `desktopMode:"local"`, but main decides to
* START from a separate launch-mode file; when those desync (a first local
* selection whose runtime start failed/was interrupted), main never starts the
* runtime and this branch would poll a permanently "stopped" runtime until the 30s
* timeout — the "hangs at Starting local runtime" bug. If the runtime is not already
* running or starting, actively (re)start it via setDesktopMode("local") — idempotent
* and awaits startup — before polling, so the gate can never wait for a runtime nobody
* launched.
*
* Re-read the runtime state immediately before deciding: the `state` snapshot was captured
* at mount and may not yet reflect a runtime main already started, which would fire a
* redundant setDesktopMode("local") on normal boots.
*/
const freshState = await shell.getState();
if (cancelled) return;
const rt = freshState.localRuntime;
if (rt?.state !== "running" && rt?.state !== "starting") {
try {
await shell.setDesktopMode("local");
} catch (startError) {
if (cancelled) return;
setPhase({
kind: "local-error",
message: startError instanceof Error ? startError.message : String(startError),
});
return;
}
if (cancelled) return;
}
const { baseUrl } = await waitForLocalRuntime(shell);
if (cancelled) return;
navigateToLocalRuntimeOrigin(baseUrl);
return;
}
setPhase({ kind: "ready" });
} catch (error) {
if (cancelled) return;
setPhase({
kind: "local-error",
message: error instanceof Error ? error.message : String(error),
});
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const shell = getFusionShell();
if (!shell?.onResetDesktopModeRequest || !shell.resetDesktopMode) {
return;
}
return shell.onResetDesktopModeRequest(() => {
void (async () => {
try {
await shell.resetDesktopMode?.();
} catch {
// Reset best-effort; we still reload to give the user a fresh
// chooser even if the IPC failed.
}
const url = new URL(window.location.href);
url.searchParams.delete("serverBaseUrl");
url.searchParams.delete("shellMode");
window.location.replace(url.toString());
})();
});
}, []);
if (phase.kind === "loading" || phase.kind === "starting-local") {
const message = phase.kind === "loading" ? t("desktop.loading", "Loading Fusion…") : phase.message;
return (
<div className="desktop-launch-gate" role="status" aria-live="polite">
<div className="desktop-launch-gate__panel">
<p>{message}</p>
</div>
</div>
);
}
if (phase.kind === "local-error") {
return (
<div className="desktop-launch-gate" role="alert">
<div className="desktop-launch-gate__panel">
<h2>{t("desktop.couldNotStart", "Couldn't start local Fusion")}</h2>
<p>{phase.message}</p>
<button
type="button"
className="btn"
onClick={() => {
window.location.reload();
}}
>
{t("desktop.retry", "Retry")}
</button>
</div>
</div>
);
}
if (phase.kind === "chooser") {
return (
<DesktopModeChooser
onPick={async (mode) => {
const shell = getFusionShell();
if (!shell) return;
setPhase({
kind: "starting-local",
message: mode === "local" ? t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") : t("desktop.settingUpRemote", "Setting up remote connection…"),
});
try {
await shell.setDesktopMode(mode);
if (mode === "local") {
const { baseUrl } = await waitForLocalRuntime(shell);
navigateToLocalRuntimeOrigin(baseUrl);
return;
}
await shell.openConnectionManager();
// After remote setup the App will pick up the active profile and
// redirect; reload to flush bootstrap state.
window.location.reload();
} catch (error) {
setPhase({
kind: "local-error",
message: error instanceof Error ? error.message : String(error),
});
}
}}
/>
);
}
return <>{children}</>;
}
function DesktopModeChooser({ onPick }: { onPick: (mode: "local" | "remote") => void }) {
const { t } = useTranslation("app");
const [pending, setPending] = useState<"local" | "remote" | null>(null);
return (
<div className="desktop-launch-gate" role="dialog" aria-labelledby="desktop-launch-gate-title">
<div className="desktop-launch-gate__panel">
<h1 id="desktop-launch-gate-title">{t("desktop.chooseMode", "How do you want to run Fusion?")}</h1>
<p>
{t("desktop.chooseModeDescription", "Run Fusion locally in this app, or connect to a Fusion server you're already running somewhere else.")}
</p>
<div className="desktop-launch-gate__actions">
<button
type="button"
className="btn btn-primary"
disabled={pending !== null}
onClick={() => {
setPending("local");
onPick("local");
}}
>
{pending === "local" ? t("desktop.starting", "Starting…") : t("desktop.runLocalButton", "Run Fusion Locally")}
</button>
<button
type="button"
className="btn"
disabled={pending !== null}
onClick={() => {
setPending("remote");
onPick("remote");
}}
>
{pending === "remote" ? t("desktop.opening", "Opening…") : t("desktop.connectRemoteButton", "Connect to Remote Fusion")}
</button>
</div>
</div>
</div>
);
}