Migration (multi-agent sweep over 216 files, 60 batches):
- Every user-visible dashboard + TUI string moved to t() with the exact
English inline default (en rendering byte-identical)
- Catalogs merged from per-batch fragments: en/zh-CN/zh-TW/fr/es now
carry ~5,930 keys each across common/app/errors/cli namespaces;
CLI bundles regenerated (6 locales incl. ko)
Integration fixes:
- 18 type errors: reserved {{count}} interpolations renamed, malformed
plural call, hand-rolled t-param types replaced with TFunction<"app">
- 23 lint errors: superseded label constants/helpers removed
- ExecutorStatusBar hook-order violation (keyboard-open early return
moved below hooks)
- TUI tests wrapped in I18nextProvider (uninitialized fallback renders
literal {{placeholders}}); dashboard vitest.setup boots a minimal en
i18next instance for the same reason
Known WIP (next commits): ~457 residual strings across 50 batches,
Korean drafts for swept keys, and a dashboard test-suite pass that is
still being stabilized (~283 failures under investigation — fake-timer
waitFor interaction, likely stale node_modules vs merged lockfile).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
247 lines
8.1 KiB
TypeScript
247 lines
8.1 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 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());
|
|
}
|
|
|
|
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") {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.has("serverBaseUrl")) {
|
|
setPhase({ kind: "ready", serverBaseUrl: params.get("serverBaseUrl") ?? undefined });
|
|
return;
|
|
}
|
|
setPhase({ kind: "starting-local", message: t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") });
|
|
const { baseUrl } = await waitForLocalRuntime(shell);
|
|
if (cancelled) return;
|
|
applyServerBaseUrl(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);
|
|
applyServerBaseUrl(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>
|
|
);
|
|
}
|