From 3b52a4d2d97f1f214e0664f3e58b780fb590ec2c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 13:43:59 -0700 Subject: [PATCH] FN-7527: fix desktop server switching redirects Route desktop shell server switches through the live runtime/profile state. - Replace separate local/remote redirect effects with a shared resolver that uses localRuntime/baseUrl and active remote profiles. - Remove the dead localServer shell state field and document the local/remote switch navigation behavior. - Add regression coverage for desktop shell redirect targets and include a patch changeset. Files changed: .changeset/fn-7527-desktop-switch-server-navigation.md | 7 + docs/native-shell.md | 2 +- packages/dashboard/app/App.tsx | 44 ++--- packages/dashboard/app/components/__tests__/App.test.tsx | 1 - packages/dashboard/app/types/native-shell.d.ts | 13 +- packages/dashboard/app/utils/__tests__/appLifecycle.test.ts | 180 +++++++++++++++++++++ packages/dashboard/app/utils/appLifecycle.ts | 70 ++++++++ 7 files changed, 280 insertions(+), 37 deletions(-) Fusion-Task-Id: FN-7527 Fusion-Task-Lineage: a0fe5cbc-120b-4a56-b791-48306223a636 Co-authored-by: Fusion (runfusion.ai) --- ...n-7527-desktop-switch-server-navigation.md | 7 + docs/native-shell.md | 2 +- packages/dashboard/app/App.tsx | 44 ++--- .../app/components/__tests__/App.test.tsx | 1 - .../dashboard/app/types/native-shell.d.ts | 13 +- .../app/utils/__tests__/appLifecycle.test.ts | 180 ++++++++++++++++++ packages/dashboard/app/utils/appLifecycle.ts | 70 +++++++ 7 files changed, 280 insertions(+), 37 deletions(-) create mode 100644 .changeset/fn-7527-desktop-switch-server-navigation.md create mode 100644 packages/dashboard/app/utils/__tests__/appLifecycle.test.ts diff --git a/.changeset/fn-7527-desktop-switch-server-navigation.md b/.changeset/fn-7527-desktop-switch-server-navigation.md new file mode 100644 index 0000000000..19784749de --- /dev/null +++ b/.changeset/fn-7527-desktop-switch-server-navigation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the in-dashboard Switch server menu not switching desktop local/remote. +category: fix +dev: The desktop shell's redirect effects in App.tsx read a dead `localServer` field that the preload never populates; extracted `resolveDesktopShellRedirectTarget` in appLifecycle.ts now derives the navigation target from the live `localRuntime`/`activeProfileId` state for both directions, and the unused `localServer` field was removed from `ShellConnectionState`. diff --git a/docs/native-shell.md b/docs/native-shell.md index df9fc26aaa..f287e0bb94 100644 --- a/docs/native-shell.md +++ b/docs/native-shell.md @@ -57,7 +57,7 @@ Profiles are first-class saved objects shared by onboarding and Connection Manag Connection Manager supports: -- **Desktop Switch server** presents the built-in **Local Server** separately from saved **Remote servers**. Local Server is always available in the desktop shell; selecting it calls `setDesktopMode("local")` and returns the shell to the embedded/local Fusion server without deleting remote profiles. +- **Desktop Switch server** presents the built-in **Local Server** separately from saved **Remote servers**. Local Server is always available in the desktop shell; selecting it calls `setDesktopMode("local")` and returns the shell to the embedded/local Fusion server without deleting remote profiles. Choosing **Local Server** or a remote profile from this in-dashboard menu navigates the renderer to the selected server's origin (the running local runtime's `baseUrl`, or the remote profile's `serverUrl`) — the same end result as switching modes from the native desktop menu (FN-7527). - **Add remote server** is the desktop CTA for saving another Fusion server profile. The remote profile editor stays collapsed until a user chooses **Add remote server** or edits an existing saved profile, so local-only desktop users do not see an empty setup form. - **Use** (activate a saved remote profile). In desktop local mode, using a remote profile first switches desktop mode back to `remote`, then activates the selected profile. - **Edit** (update name/URL/token) diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index f8d21af104..de82a6c8dd 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -88,7 +88,7 @@ import { fetchTaskDetail, fetchWorkflowSteps } from "./api"; import { SETUP_WARNING_DISMISSED_KEY, RETRY_WARNING_RATIO, - buildRemoteDashboardUrl, + resolveDesktopShellRedirectTarget, requiresNativeShellOnboarding, shouldShowFirstEverBootLoader, isSessionNeedingInputForBanner, @@ -1190,39 +1190,23 @@ function AppInner() { }; }, [shellHost.host, shellState.activeProfileId, shellState.desktopMode, shellState.host, shellState.profiles]); + /* + * FNXC:DesktopSwitchServer 2026-07-04-13:20: + * Single shared decision path for the in-dashboard "Switch server" navigation, covering BOTH directions + * (remote -> local and local -> remote). This mirrors the working native-menu / desktopLaunchMode flow by + * navigating the renderer to the selected server's origin, since the in-dashboard switch does not route + * through the Electron main-process launch-mode handlers. See resolveDesktopShellRedirectTarget in + * appLifecycle.ts for the pure decision logic and negative-state guards (not-running runtime, already-on- + * target origin, non-desktop hosts, missing active profile). + */ useEffect(() => { - if (shellState.host !== "desktop-shell") { + if (typeof window === "undefined") { return; } - if (shellState.desktopMode !== "local") { - return; - } - - if (shellState.localServer?.status !== "ready" || !shellState.localServer.port) { - return; - } - - if (window.location.port === String(shellState.localServer.port)) { - return; - } - - window.location.href = `http://localhost:${shellState.localServer.port}`; - }, [shellState]); - - useEffect(() => { - if (shellState.host !== "desktop-shell" || shellState.desktopMode !== "remote") { - return; - } - - const activeProfile = shellState.profiles.find((profile) => profile.id === shellState.activeProfileId); - if (!activeProfile || typeof window === "undefined") { - return; - } - - const nextUrl = buildRemoteDashboardUrl(activeProfile.serverUrl, activeProfile.authToken ?? null); - if (window.location.href !== nextUrl) { - window.location.href = nextUrl; + const target = resolveDesktopShellRedirectTarget(shellState, window.location.href); + if (target) { + window.location.href = target; } }, [shellState]); diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index 390ba92c94..af042f0bef 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -221,7 +221,6 @@ const mockShellConnectionState = { desktopMode: "local" as const, profiles: [], activeProfileId: null, - localServer: null, }; const mockGetShellConnectionNativeResult = vi.fn(async () => ({ diff --git a/packages/dashboard/app/types/native-shell.d.ts b/packages/dashboard/app/types/native-shell.d.ts index 275fc250f4..afc6f1b317 100644 --- a/packages/dashboard/app/types/native-shell.d.ts +++ b/packages/dashboard/app/types/native-shell.d.ts @@ -24,11 +24,14 @@ export interface ShellConnectionState { }; activeProfileId: string | null; profiles: ShellConnectionProfile[]; - localServer?: { - status: "idle" | "starting" | "ready" | "error"; - port?: number; - error?: string | null; - }; + /* + * FNXC:DesktopSwitchServer 2026-07-04-13:20: + * `localRuntime` is the only field the desktop preload/IPC ever populates for the embedded local server + * (see packages/desktop/src/ipc.ts). A previous `localServer` field here was never emitted by the shell and + * was removed after it caused the in-dashboard "Switch server" -> Local Server redirect to silently no-op + * (FN-7527); resolveDesktopShellRedirectTarget in appLifecycle.ts is the sole consumer of localRuntime for + * renderer-side navigation decisions. + */ localRuntime?: { source: "embedded-local" | "external-cli" | "none"; state: "stopped" | "starting" | "running" | "error"; diff --git a/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts b/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts new file mode 100644 index 0000000000..cd0c9e0ded --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { buildRemoteDashboardUrl, resolveDesktopShellRedirectTarget } from "../appLifecycle"; + +describe("resolveDesktopShellRedirectTarget", () => { + const remoteProfile = { + id: "remote-1", + serverUrl: "https://fusionstudio:4040", + authToken: "tok-123", + }; + + it("returns null for non-desktop-shell hosts", () => { + expect( + resolveDesktopShellRedirectTarget( + { + host: "web", + desktopMode: "local", + activeProfileId: null, + profiles: [], + localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" }, + }, + "https://fusionstudio:4040/", + ), + ).toBeNull(); + + expect( + resolveDesktopShellRedirectTarget( + { + host: "mobile-shell", + desktopMode: "local", + activeProfileId: null, + profiles: [], + localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" }, + }, + "https://fusionstudio:4040/", + ), + ).toBeNull(); + }); + + it("returns null when desktopMode is undefined", () => { + expect( + resolveDesktopShellRedirectTarget( + { + host: "desktop-shell", + activeProfileId: null, + profiles: [], + }, + "https://fusionstudio:4040/", + ), + ).toBeNull(); + }); + + it("resolves the local runtime origin (baseUrl) when switching remote -> local", () => { + const target = resolveDesktopShellRedirectTarget( + { + host: "desktop-shell", + desktopMode: "local", + activeProfileId: "remote-1", + profiles: [remoteProfile], + localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123", port: 50123 }, + }, + "https://fusionstudio:4040/", + ); + expect(target).toBe("http://127.0.0.1:50123"); + }); + + it("falls back to localhost: when localRuntime has no baseUrl", () => { + const target = resolveDesktopShellRedirectTarget( + { + host: "desktop-shell", + desktopMode: "local", + activeProfileId: null, + profiles: [], + localRuntime: { state: "running", port: 50123 }, + }, + "https://fusionstudio:4040/", + ); + expect(target).toBe("http://localhost:50123"); + }); + + it("returns null when the local runtime is not running", () => { + for (const state of ["stopped", "starting", "error"] as const) { + expect( + resolveDesktopShellRedirectTarget( + { + host: "desktop-shell", + desktopMode: "local", + activeProfileId: null, + profiles: [], + localRuntime: { state, baseUrl: "http://127.0.0.1:50123" }, + }, + "https://fusionstudio:4040/", + ), + ).toBeNull(); + } + + expect( + resolveDesktopShellRedirectTarget( + { + host: "desktop-shell", + desktopMode: "local", + activeProfileId: null, + profiles: [], + localRuntime: undefined, + }, + "https://fusionstudio:4040/", + ), + ).toBeNull(); + }); + + it("returns null when already on the local runtime origin", () => { + expect( + resolveDesktopShellRedirectTarget( + { + host: "desktop-shell", + desktopMode: "local", + activeProfileId: null, + profiles: [], + localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" }, + }, + "http://127.0.0.1:50123/", + ), + ).toBeNull(); + }); + + it("resolves buildRemoteDashboardUrl(...) when switching local -> a remote profile", () => { + const target = resolveDesktopShellRedirectTarget( + { + host: "desktop-shell", + desktopMode: "remote", + activeProfileId: "remote-1", + profiles: [remoteProfile], + localRuntime: { state: "stopped" }, + }, + "http://127.0.0.1:50123/", + ); + expect(target).toBe(buildRemoteDashboardUrl(remoteProfile.serverUrl, remoteProfile.authToken)); + }); + + it("returns null when already on the target remote url", () => { + const nextUrl = buildRemoteDashboardUrl(remoteProfile.serverUrl, remoteProfile.authToken); + expect( + resolveDesktopShellRedirectTarget( + { + host: "desktop-shell", + desktopMode: "remote", + activeProfileId: "remote-1", + profiles: [remoteProfile], + }, + nextUrl, + ), + ).toBeNull(); + }); + + it("returns null when there is no matching/active profile", () => { + expect( + resolveDesktopShellRedirectTarget( + { + host: "desktop-shell", + desktopMode: "remote", + activeProfileId: null, + profiles: [remoteProfile], + }, + "http://127.0.0.1:50123/", + ), + ).toBeNull(); + + expect( + resolveDesktopShellRedirectTarget( + { + host: "desktop-shell", + desktopMode: "remote", + activeProfileId: "missing", + profiles: [remoteProfile], + }, + "http://127.0.0.1:50123/", + ), + ).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/utils/appLifecycle.ts b/packages/dashboard/app/utils/appLifecycle.ts index 0855df94cb..c0887f5016 100644 --- a/packages/dashboard/app/utils/appLifecycle.ts +++ b/packages/dashboard/app/utils/appLifecycle.ts @@ -73,6 +73,76 @@ export function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | return url.toString(); } +export interface DesktopShellRedirectShellState { + host: "web" | "mobile-shell" | "desktop-shell"; + desktopMode?: "local" | "remote"; + activeProfileId: string | null; + profiles: Array<{ id: string; serverUrl: string; authToken?: string | null }>; + localRuntime?: { + state: "stopped" | "starting" | "running" | "error"; + port?: number; + baseUrl?: string; + }; +} + +/* + * FNXC:DesktopSwitchServer 2026-07-04-13:20: + * The in-dashboard "Switch server" (Connection Manager) affordance calls `shellApi.setDesktopMode(...)` / + * `setActiveProfile(...)` directly and relies on THIS renderer-side redirect to navigate, because it does not + * route through the Electron main-process launch-mode handlers (`desktopLaunchMode:setMode` in + * packages/desktop/src/main.ts) the way the native menu does. The desktop preload only ever emits the live + * `localRuntime` field (see native-shell.d.ts / packages/desktop/src/ipc.ts) — the legacy `localServer` field is + * never populated and must not be used. This helper is the single decision point shared by both the local- and + * remote-redirect effects in App.tsx so both switch directions behave like the working native-menu path and + * so neither introduces a reload loop. + */ +export function resolveDesktopShellRedirectTarget( + shellState: DesktopShellRedirectShellState, + currentHref: string, +): string | null { + if (shellState.host !== "desktop-shell") { + return null; + } + + if (shellState.desktopMode === "local") { + const runtime = shellState.localRuntime; + if (!runtime || runtime.state !== "running") { + return null; + } + const baseUrl = runtime.baseUrl || (runtime.port ? `http://localhost:${runtime.port}` : undefined); + if (!baseUrl) { + return null; + } + if (currentHref === baseUrl) { + return null; + } + try { + const current = new URL(currentHref); + const target = new URL(baseUrl); + if (current.origin === target.origin) { + return null; + } + } catch { + // fall through and navigate — currentHref/baseUrl were not parseable URLs + } + return baseUrl; + } + + if (shellState.desktopMode === "remote") { + const activeProfile = shellState.profiles.find((profile) => profile.id === shellState.activeProfileId); + if (!activeProfile) { + return null; + } + const nextUrl = buildRemoteDashboardUrl(activeProfile.serverUrl, activeProfile.authToken ?? null); + if (currentHref === nextUrl) { + return null; + } + return nextUrl; + } + + return null; +} + export function requiresNativeShellOnboarding( shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null }, shellReady: boolean,