feat(FN-3400): add native shell connection handoff, plugin management CLI/l

This merge adds a complete plugin management system (FN-3565) with CLI commands, a loader, runner, and dashboard routes, along with project-scoped auth storage (FN-3544), native shell connection support for mobile (FN-3400) spanning onboarding, connection manager, and remote desktop handoff, and ref

Fusion-Task-Id: FN-3400
This commit is contained in:
Fusion
2026-05-06 05:24:01 -07:00
committed by gsxdsm
parent fb0e15562d
commit 419c6f3b31
20 changed files with 266 additions and 25 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add native-shell remote connection management across desktop/mobile, including saved server profiles, optional auth token support, and shell-owned connection switching APIs used by dashboard onboarding/connection UI.

View File

@@ -116,6 +116,14 @@ function prefetchLazyViews() {
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed"; const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
const url = new URL(serverUrl);
if (authToken) {
url.searchParams.set("rt", authToken);
}
return url.toString();
}
export function requiresNativeShellOnboarding( export function requiresNativeShellOnboarding(
shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null }, shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null },
shellReady: boolean, shellReady: boolean,
@@ -138,7 +146,7 @@ export function requiresNativeShellOnboarding(
function AppInner() { function AppInner() {
const { toasts, addToast, removeToast } = useToast(); const { toasts, addToast, removeToast } = useToast();
const { shellApi, state: shellState, ready: shellReady } = useShellConnection(); const { shellApi, state: shellState, ready: shellReady, openConnectionManagerSignal } = useShellConnection();
const isElectron = typeof window !== "undefined" && Boolean((window as Window & { electronAPI?: unknown }).electronAPI); const isElectron = typeof window !== "undefined" && Boolean((window as Window & { electronAPI?: unknown }).electronAPI);
// Warm lazy view chunks during browser idle so first navigation is instant. // Warm lazy view chunks during browser idle so first navigation is instant.
@@ -794,6 +802,13 @@ function AppInner() {
const requiresShellOnboarding = requiresNativeShellOnboarding(shellState, shellReady, shellOnboardingComplete); const requiresShellOnboarding = requiresNativeShellOnboarding(shellState, shellReady, shellOnboardingComplete);
useEffect(() => {
if (!shellApi || openConnectionManagerSignal === 0) {
return;
}
setShellConnectionManagerOpen(true);
}, [shellApi, openConnectionManagerSignal]);
useEffect(() => { useEffect(() => {
if (shellState.host !== "desktop-shell") { if (shellState.host !== "desktop-shell") {
return; return;
@@ -814,6 +829,22 @@ function AppInner() {
window.location.href = `http://localhost:${shellState.localServer.port}`; window.location.href = `http://localhost:${shellState.localServer.port}`;
}, [shellState]); }, [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;
}
}, [shellState]);
const showBackendConnectionErrorPage = const showBackendConnectionErrorPage =
!projectsLoading && !projectsLoading &&
!currentProjectLoading && !currentProjectLoading &&
@@ -1314,9 +1345,6 @@ function AppInner() {
nodesView: nodesEnabled, nodesView: nodesEnabled,
}} }}
pluginDashboardViews={pluginDashboardViews} pluginDashboardViews={pluginDashboardViews}
shellConnectionControl={shellApi && shellState.host !== "web" ? (
<NativeShellConnectionStatus state={shellState} onManage={() => setShellConnectionManagerOpen(true)} />
) : undefined}
/> />
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !isPluginViewId(taskView) && ( {viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !isPluginViewId(taskView) && (
<QuickChatFAB <QuickChatFAB

View File

@@ -12,6 +12,16 @@ describe("App shell onboarding gating", () => {
).toBe(true); ).toBe(true);
}); });
it("requires onboarding for desktop remote mode without active profile", () => {
expect(
requiresNativeShellOnboarding(
{ host: "desktop-shell", desktopMode: "remote", activeProfileId: null },
true,
false,
),
).toBe(true);
});
it("skips onboarding for desktop local mode", () => { it("skips onboarding for desktop local mode", () => {
expect( expect(
requiresNativeShellOnboarding( requiresNativeShellOnboarding(

View File

@@ -0,0 +1,41 @@
import { render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ShellProvider, useShellContext } from "../context/ShellContext";
function Probe() {
const { openConnectionManagerSignal } = useShellContext();
return <div data-testid="signal">{openConnectionManagerSignal}</div>;
}
describe("ShellProvider", () => {
beforeEach(() => {
window.fusionShell = {
getState: vi.fn(async () => ({ host: "mobile-shell", activeProfileId: null, profiles: [] })),
listProfiles: vi.fn(async () => []),
saveProfile: vi.fn(),
deleteProfile: vi.fn(),
setActiveProfile: vi.fn(),
setDesktopMode: vi.fn(),
startQrScan: vi.fn(),
openConnectionManager: vi.fn(async () => {
window.dispatchEvent(new CustomEvent("shell:open-connection-manager"));
}),
subscribe: vi.fn(() => () => undefined),
} as never;
});
it("increments open-connection-manager signal when event fires", async () => {
const { getByTestId } = render(
<ShellProvider>
<Probe />
</ShellProvider>,
);
expect(getByTestId("signal").textContent).toBe("0");
window.dispatchEvent(new CustomEvent("shell:open-connection-manager"));
await waitFor(() => {
expect(getByTestId("signal").textContent).toBe("1");
});
});
});

View File

@@ -0,0 +1,17 @@
.project-overview-empty {
display: flex;
flex-direction: column;
gap: var(--space-md);
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-2xl);
max-width: 40rem;
margin: 0 auto;
}
@media (max-width: 768px) {
.project-overview-empty {
padding: var(--space-lg);
}
}

View File

@@ -1,3 +1,5 @@
import "./BackendConnectionErrorPage.css";
interface BackendConnectionErrorPageProps { interface BackendConnectionErrorPageProps {
errorMessage: string; errorMessage: string;
isRetrying: boolean; isRetrying: boolean;

View File

@@ -1,6 +1,5 @@
import "./MobileNavBar.css"; import "./MobileNavBar.css";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { import {
Activity, Activity,
Bot, Bot,
@@ -86,7 +85,6 @@ export interface MobileNavBarProps {
}; };
onOpenNodes?: () => void; onOpenNodes?: () => void;
pluginDashboardViews?: PluginDashboardViewEntry[]; pluginDashboardViews?: PluginDashboardViewEntry[];
shellConnectionControl?: ReactNode;
} }
function GitHubLogo({ size = 20 }: { size?: number }) { function GitHubLogo({ size = 20 }: { size?: number }) {
@@ -138,10 +136,8 @@ export function MobileNavBar({
experimentalFeatures, experimentalFeatures,
onOpenNodes, onOpenNodes,
pluginDashboardViews = [], pluginDashboardViews = [],
shellConnectionControl,
}: MobileNavBarProps) { }: MobileNavBarProps) {
const mode = useViewportMode(); const mode = useViewportMode();
void shellConnectionControl;
const [isMoreOpen, setIsMoreOpen] = useState(false); const [isMoreOpen, setIsMoreOpen] = useState(false);
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false); const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({}); const [scripts, setScripts] = useState<Record<string, string>>({});

View File

@@ -26,6 +26,11 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
const saveCurrent = async () => { const saveCurrent = async () => {
setError(null); setError(null);
try { try {
// Early validation for user feedback before bridge persistence rejects.
const parsed = new URL(workingUrl.trim());
if (!/^https?:$/.test(parsed.protocol)) {
throw new Error("Server URL must use http or https");
}
const saved = await shellApi.saveProfile({ const saved = await shellApi.saveProfile({
id: activeProfile?.id, id: activeProfile?.id,
name: workingName || "Remote Server", name: workingName || "Remote Server",
@@ -64,9 +69,9 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
<div className="settings-muted">{profile.serverUrl}</div> <div className="settings-muted">{profile.serverUrl}</div>
</div> </div>
<div className="native-shell-connection-manager__profile-actions"> <div className="native-shell-connection-manager__profile-actions">
<button type="button" className="btn btn-sm" onClick={() => setDraft(profile)}>Edit</button> <button type="button" className="btn btn-sm" aria-label={`Edit ${profile.name}`} onClick={() => setDraft(profile)}>Edit</button>
<button type="button" className="btn btn-sm" onClick={() => void shellApi.setActiveProfile(profile.id)}>Use</button> <button type="button" className="btn btn-sm" aria-label={`Use ${profile.name}`} onClick={() => void shellApi.setActiveProfile(profile.id)}>Use</button>
<button type="button" className="btn btn-sm btn-danger" onClick={() => void shellApi.deleteProfile(profile.id)}>Delete</button> <button type="button" className="btn btn-sm btn-danger" aria-label={`Delete ${profile.name}`} onClick={() => void shellApi.deleteProfile(profile.id)}>Delete</button>
</div> </div>
</div> </div>
))} ))}
@@ -78,8 +83,8 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
<label htmlFor="native-shell-connection-manager-url">Server URL</label> <label htmlFor="native-shell-connection-manager-url">Server URL</label>
<input id="native-shell-connection-manager-url" className="input" value={workingUrl} onChange={(event) => setDraft((value) => ({ ...value, serverUrl: event.target.value }))} /> <input id="native-shell-connection-manager-url" className="input" value={workingUrl} onChange={(event) => setDraft((value) => ({ ...value, serverUrl: event.target.value }))} />
<label htmlFor="native-shell-connection-manager-token">Auth token (optional)</label> <label htmlFor="native-shell-connection-manager-token">Auth token (optional)</label>
<input id="native-shell-connection-manager-token" className="input" value={workingToken ?? ""} onChange={(event) => setDraft((value) => ({ ...value, authToken: event.target.value }))} /> <input id="native-shell-connection-manager-token" className="input" type="password" value={workingToken ?? ""} onChange={(event) => setDraft((value) => ({ ...value, authToken: event.target.value }))} />
{error && <p className="form-error">{error}</p>} {error && <p className="form-error" role="alert">{error}</p>}
</div> </div>
<div className="modal-actions"> <div className="modal-actions">

View File

@@ -5,7 +5,7 @@ import "./NativeShellOnboardingModal.css";
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string { function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
const url = new URL(serverUrl); const url = new URL(serverUrl);
if (authToken) { if (authToken) {
url.searchParams.set("token", authToken); url.searchParams.set("rt", authToken);
} }
return url.toString(); return url.toString();
} }
@@ -23,6 +23,8 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
const [serverUrl, setServerUrl] = useState(""); const [serverUrl, setServerUrl] = useState("");
const [authToken, setAuthToken] = useState(""); const [authToken, setAuthToken] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const [submitting, setSubmitting] = useState(false);
const isDesktop = shellState.host === "desktop-shell"; const isDesktop = shellState.host === "desktop-shell";
@@ -61,34 +63,39 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
className="btn" className="btn"
onClick={async () => { onClick={async () => {
setError(null); setError(null);
setScanning(true);
try { try {
const result = await shellApi.startQrScan(); const result = await shellApi.startQrScan();
setServerUrl(result.serverUrl); setServerUrl(result.serverUrl);
setAuthToken(result.authToken ?? ""); setAuthToken(result.authToken ?? "");
} catch (scanError) { } catch (scanError) {
setError((scanError as Error).message); setError((scanError as Error).message);
} finally {
setScanning(false);
} }
}} }}
disabled={scanning}
> >
Scan QR {scanning ? "Scanning…" : "Scan QR"}
</button> </button>
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-profile-name">Profile name</label> <label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-profile-name">Profile name</label>
<input id="native-shell-onboarding-profile-name" className="input" value={name} onChange={(event) => setName(event.target.value)} /> <input id="native-shell-onboarding-profile-name" className="input" value={name} onChange={(event) => setName(event.target.value)} />
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-server-url">Server URL</label> <label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-server-url">Server URL</label>
<input id="native-shell-onboarding-server-url" className="input" value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} placeholder="https://your-fusion-host" /> <input id="native-shell-onboarding-server-url" className="input" value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} placeholder="https://your-fusion-host" />
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-auth-token">Auth token (optional)</label> <label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-auth-token">Auth token (optional)</label>
<input id="native-shell-onboarding-auth-token" className="input" value={authToken} onChange={(event) => setAuthToken(event.target.value)} /> <input id="native-shell-onboarding-auth-token" className="input" type="password" value={authToken} onChange={(event) => setAuthToken(event.target.value)} />
</> </>
)} )}
{error && <p className="form-error">{error}</p>} {error && <p className="form-error" role="alert">{error}</p>}
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
disabled={!canSubmit} disabled={!canSubmit || submitting}
onClick={async () => { onClick={async () => {
setError(null); setError(null);
setSubmitting(true);
try { try {
if (isDesktop && mode === "local") { if (isDesktop && mode === "local") {
await shellApi.setDesktopMode("local"); await shellApi.setDesktopMode("local");
@@ -115,10 +122,12 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
onComplete(); onComplete();
} catch (submitError) { } catch (submitError) {
setError((submitError as Error).message); setError((submitError as Error).message);
} finally {
setSubmitting(false);
} }
}} }}
> >
Continue {submitting ? "Saving…" : "Continue"}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -32,6 +32,26 @@ describe("NativeShellConnectionManager", () => {
await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local")); await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local"));
}); });
it("activates and deletes profiles", async () => {
const shellApi = createShellApi();
render(
<NativeShellConnectionManager
open={true}
shellApi={shellApi}
shellState={{ host: "mobile-shell", activeProfileId: null, profiles: [{ id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", authToken: null, createdAt: "", updatedAt: "" }] }}
onClose={vi.fn()}
/>,
);
fireEvent.click(screen.getByLabelText("Use Prod"));
fireEvent.click(screen.getByLabelText("Delete Prod"));
await waitFor(() => {
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("p1");
expect(shellApi.deleteProfile).toHaveBeenCalledWith("p1");
});
});
it("edits and saves active profile", async () => { it("edits and saves active profile", async () => {
const shellApi = createShellApi(); const shellApi = createShellApi();
render( render(

View File

@@ -27,8 +27,37 @@ describe("NativeShellOnboardingModal", () => {
expect(screen.getByText("Remote Server")).toBeInTheDocument(); expect(screen.getByText("Remote Server")).toBeInTheDocument();
}); });
it("applies QR scan results", async () => {
const startQrScan = vi.fn(async () => ({ serverUrl: "https://qr.example.com", authToken: "token-1" }));
render(
<NativeShellOnboardingModal
open={true}
shellApi={{
getState: vi.fn(),
listProfiles: vi.fn(),
saveProfile: vi.fn(),
deleteProfile: vi.fn(),
setActiveProfile: vi.fn(),
setDesktopMode: vi.fn(),
startQrScan,
openConnectionManager: vi.fn(),
subscribe: vi.fn(() => () => undefined),
}}
shellState={{ host: "mobile-shell", activeProfileId: null, profiles: [] }}
onComplete={vi.fn()}
/>,
);
fireEvent.click(screen.getByText("Scan QR"));
await waitFor(() => {
expect(screen.getByDisplayValue("https://qr.example.com")).toBeInTheDocument();
expect(screen.getByDisplayValue("token-1")).toBeInTheDocument();
});
});
it("saves remote profile and redirects to remote dashboard", async () => { it("saves remote profile and redirects to remote dashboard", async () => {
const saveProfile = vi.fn(async () => ({ id: "p1", serverUrl: "https://fusion.example.com", authToken: null })); const saveProfile = vi.fn(async () => ({ id: "p1", serverUrl: "https://fusion.example.com", authToken: "abc" }));
const setActiveProfile = vi.fn(async () => ({ host: "mobile-shell", activeProfileId: "p1", profiles: [] })); const setActiveProfile = vi.fn(async () => ({ host: "mobile-shell", activeProfileId: "p1", profiles: [] }));
const onComplete = vi.fn(); const onComplete = vi.fn();
const originalLocation = window.location; const originalLocation = window.location;
@@ -63,6 +92,7 @@ describe("NativeShellOnboardingModal", () => {
expect(saveProfile).toHaveBeenCalled(); expect(saveProfile).toHaveBeenCalled();
expect(setActiveProfile).toHaveBeenCalledWith("p1"); expect(setActiveProfile).toHaveBeenCalledWith("p1");
expect(window.location.href).toContain("https://fusion.example.com"); expect(window.location.href).toContain("https://fusion.example.com");
expect(window.location.href).toContain("rt=abc");
}); });
Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); Object.defineProperty(window, "location", { configurable: true, value: originalLocation });

View File

@@ -5,6 +5,7 @@ export interface ShellContextValue {
shellApi: FusionShellApi | null; shellApi: FusionShellApi | null;
state: ShellConnectionState; state: ShellConnectionState;
ready: boolean; ready: boolean;
openConnectionManagerSignal: number;
} }
const DEFAULT_STATE: ShellConnectionState = { const DEFAULT_STATE: ShellConnectionState = {
@@ -17,12 +18,14 @@ const ShellContext = createContext<ShellContextValue>({
shellApi: null, shellApi: null,
state: DEFAULT_STATE, state: DEFAULT_STATE,
ready: true, ready: true,
openConnectionManagerSignal: 0,
}); });
export function ShellProvider({ children }: PropsWithChildren) { export function ShellProvider({ children }: PropsWithChildren) {
const shellApi = useMemo(() => (typeof window !== "undefined" ? window.fusionShell ?? null : null), []); const shellApi = useMemo(() => (typeof window !== "undefined" ? window.fusionShell ?? null : null), []);
const [state, setState] = useState<ShellConnectionState>(DEFAULT_STATE); const [state, setState] = useState<ShellConnectionState>(DEFAULT_STATE);
const [ready, setReady] = useState(!shellApi); const [ready, setReady] = useState(!shellApi);
const [openConnectionManagerSignal, setOpenConnectionManagerSignal] = useState(0);
useEffect(() => { useEffect(() => {
if (!shellApi) { if (!shellApi) {
@@ -41,13 +44,19 @@ export function ShellProvider({ children }: PropsWithChildren) {
setState(nextState); setState(nextState);
}); });
const handleOpenConnectionManager = () => {
setOpenConnectionManagerSignal((value) => value + 1);
};
window.addEventListener("shell:open-connection-manager", handleOpenConnectionManager);
return () => { return () => {
cancelled = true; cancelled = true;
unsubscribe(); unsubscribe();
window.removeEventListener("shell:open-connection-manager", handleOpenConnectionManager);
}; };
}, [shellApi]); }, [shellApi]);
return <ShellContext.Provider value={{ shellApi, state, ready }}>{children}</ShellContext.Provider>; return <ShellContext.Provider value={{ shellApi, state, ready, openConnectionManagerSignal }}>{children}</ShellContext.Provider>;
} }
export function useShellContext(): ShellContextValue { export function useShellContext(): ShellContextValue {

View File

@@ -127,4 +127,16 @@ describe("ipc handlers", () => {
expect(onDesktopModeChange).toHaveBeenCalledWith("local"); expect(onDesktopModeChange).toHaveBeenCalledWith("local");
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object)); expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
}); });
it("shell:saveProfile rejects invalid URLs", async () => {
await registerHandlers();
const handler = mocks.ipcHandlers.get("shell:saveProfile");
await expect(handler?.({}, { name: "Prod", serverUrl: "not-a-url" })).rejects.toThrow(
"Server URL must be a valid absolute URL",
);
await expect(handler?.({}, { name: "Prod", serverUrl: "ftp://fusion.example.com" })).rejects.toThrow(
"Server URL must use http or https",
);
});
}); });

View File

@@ -43,6 +43,20 @@ function createProfileId(): string {
return `profile_${Math.random().toString(36).slice(2, 10)}`; return `profile_${Math.random().toString(36).slice(2, 10)}`;
} }
function normalizeServerUrl(serverUrl: string): string {
const normalized = serverUrl.trim().replace(/\/$/, "");
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error("Server URL must be a valid absolute URL");
}
if (!parsed.protocol || !/^https?:$/.test(parsed.protocol)) {
throw new Error("Server URL must use http or https");
}
return normalized;
}
function toShellState( function toShellState(
settings: Awaited<ReturnType<typeof readShellSettings>>, settings: Awaited<ReturnType<typeof readShellSettings>>,
localServerState?: DesktopLocalServerState, localServerState?: DesktopLocalServerState,
@@ -113,7 +127,7 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
const nextProfile: ShellConnectionProfile = { const nextProfile: ShellConnectionProfile = {
id: existing?.id ?? profile.id ?? createProfileId(), id: existing?.id ?? profile.id ?? createProfileId(),
name: profile.name.trim(), name: profile.name.trim(),
serverUrl: profile.serverUrl.trim().replace(/\/$/, ""), serverUrl: normalizeServerUrl(profile.serverUrl),
authToken: profile.authToken ?? null, authToken: profile.authToken ?? null,
createdAt: existing?.createdAt ?? timestamp, createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp, updatedAt: timestamp,

View File

@@ -31,6 +31,17 @@ describe("connection-profiles", () => {
}); });
}); });
it("rejects invalid server URLs", async () => {
const { saveShellProfile } = await import("../plugins/connection-profiles.js");
await expect(saveShellProfile({ name: "Prod", serverUrl: "not-a-url" })).rejects.toThrow(
"Server URL must be a valid absolute URL",
);
await expect(saveShellProfile({ name: "Prod", serverUrl: "ftp://fusion.example.com" })).rejects.toThrow(
"Server URL must use http or https",
);
});
it("clears active profile when deleted", async () => { it("clears active profile when deleted", async () => {
const { saveShellProfile, setActiveShellProfile, loadShellProfiles, deleteShellProfile } = await import("../plugins/connection-profiles.js"); const { saveShellProfile, setActiveShellProfile, loadShellProfiles, deleteShellProfile } = await import("../plugins/connection-profiles.js");

View File

@@ -53,6 +53,21 @@ describe("MobileNativeShellBridge", () => {
unsubscribe(); unsubscribe();
}); });
it("dispatches shell open manager event", async () => {
const { MobileNativeShellBridge } = await import("../plugins/native-shell.js");
const bridge = new MobileNativeShellBridge(scanner as never);
const listener = vi.fn();
const originalWindow = (globalThis as { window?: Window }).window;
const mockWindow = new EventTarget() as Window;
(globalThis as { window?: Window }).window = mockWindow;
mockWindow.addEventListener("shell:open-connection-manager", listener as EventListener);
await bridge.openConnectionManager();
expect(listener).toHaveBeenCalledTimes(1);
(globalThis as { window?: Window }).window = originalWindow;
});
it("rejects desktop mode switch", async () => { it("rejects desktop mode switch", async () => {
const { MobileNativeShellBridge } = await import("../plugins/native-shell.js"); const { MobileNativeShellBridge } = await import("../plugins/native-shell.js");
const bridge = new MobileNativeShellBridge(scanner as never); const bridge = new MobileNativeShellBridge(scanner as never);

View File

@@ -12,6 +12,11 @@ describe("qr-scanner", () => {
expect(parsed).toEqual({ serverUrl: "https://fusion.example.com", authToken: "abc" }); expect(parsed).toEqual({ serverUrl: "https://fusion.example.com", authToken: "abc" });
}); });
it("parses remote-login rt token payload", () => {
const parsed = parseQrConnectionPayload("https://fusion.example.com/remote-login?rt=abc");
expect(parsed).toEqual({ serverUrl: "https://fusion.example.com", authToken: "abc" });
});
it("uses adapter scanning", async () => { it("uses adapter scanning", async () => {
const scanner = new QrScanner({ scan: vi.fn(async () => "https://fusion.example.com") }); const scanner = new QrScanner({ scan: vi.fn(async () => "https://fusion.example.com") });
await expect(scanner.scanConnection()).resolves.toEqual({ serverUrl: "https://fusion.example.com", authToken: null }); await expect(scanner.scanConnection()).resolves.toEqual({ serverUrl: "https://fusion.example.com", authToken: null });

View File

@@ -17,7 +17,17 @@ function createId(): string {
} }
function normalizeUrl(serverUrl: string): string { function normalizeUrl(serverUrl: string): string {
return serverUrl.trim().replace(/\/$/, ""); const normalized = serverUrl.trim().replace(/\/$/, "");
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error("Server URL must be a valid absolute URL");
}
if (!parsed.protocol || !/^https?:$/.test(parsed.protocol)) {
throw new Error("Server URL must use http or https");
}
return normalized;
} }
function toPersisted(input: unknown): PersistedShellState { function toPersisted(input: unknown): PersistedShellState {

View File

@@ -69,7 +69,9 @@ export class MobileNativeShellBridge implements FusionShellApi {
} }
async openConnectionManager(): Promise<void> { async openConnectionManager(): Promise<void> {
// Handled by dashboard shell context state. if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("shell:open-connection-manager"));
}
} }
subscribe(listener: Listener): () => void { subscribe(listener: Listener): () => void {

View File

@@ -27,7 +27,7 @@ function parsePayload(raw: string): QrScanResult {
try { try {
const url = new URL(trimmed); const url = new URL(trimmed);
const authToken = url.searchParams.get("authToken"); const authToken = url.searchParams.get("authToken") ?? url.searchParams.get("rt");
return { return {
serverUrl: `${url.protocol}//${url.host}`, serverUrl: `${url.protocol}//${url.host}`,
authToken, authToken,