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 { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { RootErrorBoundary } from "./components/ErrorBoundary";
|
import { RootErrorBoundary } from "./components/ErrorBoundary";
|
||||||
|
import { DesktopLaunchGate } from "./components/DesktopLaunchGate";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { installAuthFetch } from "./auth";
|
import { installAuthFetch } from "./auth";
|
||||||
import { installVersionCheck } from "./versionCheck";
|
import { installVersionCheck } from "./versionCheck";
|
||||||
@@ -21,7 +22,9 @@ registerBundledPluginViews();
|
|||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<RootErrorBoundary>
|
<RootErrorBoundary>
|
||||||
<App />
|
<DesktopLaunchGate>
|
||||||
|
<App />
|
||||||
|
</DesktopLaunchGate>
|
||||||
</RootErrorBoundary>
|
</RootErrorBoundary>
|
||||||
</StrictMode>,
|
</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 {
|
export interface ShellConnectionState {
|
||||||
host: "web" | "mobile-shell" | "desktop-shell";
|
host: "web" | "mobile-shell" | "desktop-shell";
|
||||||
desktopMode?: "local" | "remote";
|
desktopMode?: "local" | "remote";
|
||||||
|
desktopModeState?: {
|
||||||
|
isFirstRun: boolean;
|
||||||
|
desktopMode: "local" | "remote" | null;
|
||||||
|
};
|
||||||
activeProfileId: string | null;
|
activeProfileId: string | null;
|
||||||
profiles: ShellConnectionProfile[];
|
profiles: ShellConnectionProfile[];
|
||||||
localServer?: {
|
localServer?: {
|
||||||
@@ -25,6 +29,13 @@ export interface ShellConnectionState {
|
|||||||
port?: number;
|
port?: number;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
};
|
};
|
||||||
|
localRuntime?: {
|
||||||
|
source: "embedded-local" | "external-cli" | "none";
|
||||||
|
state: "stopped" | "starting" | "running" | "error";
|
||||||
|
port?: number;
|
||||||
|
baseUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FusionShellApi {
|
export interface FusionShellApi {
|
||||||
|
|||||||
Reference in New Issue
Block a user