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:
5
.changeset/desktop-launch-gate.md
Normal file
5
.changeset/desktop-launch-gate.md
Normal 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.
|
||||
42
packages/dashboard/app/components/DesktopLaunchGate.css
Normal file
42
packages/dashboard/app/components/DesktopLaunchGate.css
Normal 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;
|
||||
}
|
||||
223
packages/dashboard/app/components/DesktopLaunchGate.tsx
Normal file
223
packages/dashboard/app/components/DesktopLaunchGate.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
11
packages/dashboard/app/types/native-shell.d.ts
vendored
11
packages/dashboard/app/types/native-shell.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user