feat(dashboard): desktop launch gate for run-local vs connect-remote

Wrap <App /> in a DesktopLaunchGate that runs only inside the Electron shell.
On first run it shows a chooser (Run Locally / Connect Remote). On "local"
it calls setDesktopMode("local") so main starts the embedded runtime, polls
shell:getState until localRuntime.state === "running", then reloads with
?serverBaseUrl=http://127.0.0.1:<port> so the dashboard's API calls route
to the embedded server (file:// origins can't resolve relative /api). On
"remote" it opens the existing connection manager. Replaces the dead-end
"can't reach backend" landing in the packaged desktop app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-17 22:10:58 -07:00
parent e5af9c93ae
commit b143f6c88b
5 changed files with 285 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add a desktop launch gate so the packaged Fusion app prompts the user to either run Fusion locally (starts the embedded runtime and points the dashboard at it via `?serverBaseUrl=…`) or connect to a remote Fusion server, instead of immediately showing a "can't reach backend" error.

View File

@@ -0,0 +1,42 @@
.desktop-launch-gate {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary, #0f1117);
color: var(--text-primary, #e5e7eb);
z-index: 9999;
padding: 1.5rem;
}
.desktop-launch-gate__panel {
max-width: 420px;
width: 100%;
text-align: center;
background: var(--bg-elevated, #1a1d27);
border: 1px solid var(--border-subtle, #2a2f3a);
border-radius: 12px;
padding: 2rem 1.75rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.desktop-launch-gate__panel h1,
.desktop-launch-gate__panel h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
}
.desktop-launch-gate__panel p {
margin: 0 0 1.25rem;
color: var(--text-secondary, #9ca3af);
font-size: 0.9375rem;
line-height: 1.5;
}
.desktop-launch-gate__actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

View File

@@ -0,0 +1,223 @@
import { useEffect, useState, type PropsWithChildren } from "react";
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 [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: "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;
};
}, []);
if (phase.kind === "loading" || phase.kind === "starting-local") {
const message = phase.kind === "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>Couldn&apos;t start local Fusion</h2>
<p>{phase.message}</p>
<button
type="button"
className="btn"
onClick={() => {
window.location.reload();
}}
>
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" ? "Starting local Fusion runtime…" : "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 [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">How do you want to run Fusion?</h1>
<p>
Run Fusion locally in this app, or connect to a Fusion server you&apos;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" ? "Starting…" : "Run Fusion Locally"}
</button>
<button
type="button"
className="btn"
disabled={pending !== null}
onClick={() => {
setPending("remote");
onPick("remote");
}}
>
{pending === "remote" ? "Opening…" : "Connect to Remote Fusion"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RootErrorBoundary } from "./components/ErrorBoundary";
import { DesktopLaunchGate } from "./components/DesktopLaunchGate";
import { App } from "./App";
import { installAuthFetch } from "./auth";
import { installVersionCheck } from "./versionCheck";
@@ -21,7 +22,9 @@ registerBundledPluginViews();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RootErrorBoundary>
<App />
<DesktopLaunchGate>
<App />
</DesktopLaunchGate>
</RootErrorBoundary>
</StrictMode>,
);

View File

@@ -18,6 +18,10 @@ export interface ShellConnectionProfileInput {
export interface ShellConnectionState {
host: "web" | "mobile-shell" | "desktop-shell";
desktopMode?: "local" | "remote";
desktopModeState?: {
isFirstRun: boolean;
desktopMode: "local" | "remote" | null;
};
activeProfileId: string | null;
profiles: ShellConnectionProfile[];
localServer?: {
@@ -25,6 +29,13 @@ export interface ShellConnectionState {
port?: number;
error?: string | null;
};
localRuntime?: {
source: "embedded-local" | "external-cli" | "none";
state: "stopped" | "starting" | "running" | "error";
port?: number;
baseUrl?: string;
error?: string;
};
}
export interface FusionShellApi {