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:
5
.changeset/fn-3400-native-shell-connection.md
Normal file
5
.changeset/fn-3400-native-shell-connection.md
Normal 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.
|
||||
@@ -116,6 +116,14 @@ function prefetchLazyViews() {
|
||||
|
||||
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(
|
||||
shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null },
|
||||
shellReady: boolean,
|
||||
@@ -138,7 +146,7 @@ export function requiresNativeShellOnboarding(
|
||||
|
||||
function AppInner() {
|
||||
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);
|
||||
|
||||
// Warm lazy view chunks during browser idle so first navigation is instant.
|
||||
@@ -794,6 +802,13 @@ function AppInner() {
|
||||
|
||||
const requiresShellOnboarding = requiresNativeShellOnboarding(shellState, shellReady, shellOnboardingComplete);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shellApi || openConnectionManagerSignal === 0) {
|
||||
return;
|
||||
}
|
||||
setShellConnectionManagerOpen(true);
|
||||
}, [shellApi, openConnectionManagerSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shellState.host !== "desktop-shell") {
|
||||
return;
|
||||
@@ -814,6 +829,22 @@ function AppInner() {
|
||||
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;
|
||||
}
|
||||
}, [shellState]);
|
||||
|
||||
const showBackendConnectionErrorPage =
|
||||
!projectsLoading &&
|
||||
!currentProjectLoading &&
|
||||
@@ -1314,9 +1345,6 @@ function AppInner() {
|
||||
nodesView: nodesEnabled,
|
||||
}}
|
||||
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) && (
|
||||
<QuickChatFAB
|
||||
|
||||
@@ -12,6 +12,16 @@ describe("App shell onboarding gating", () => {
|
||||
).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", () => {
|
||||
expect(
|
||||
requiresNativeShellOnboarding(
|
||||
|
||||
41
packages/dashboard/app/__tests__/ShellContext.test.tsx
Normal file
41
packages/dashboard/app/__tests__/ShellContext.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./BackendConnectionErrorPage.css";
|
||||
|
||||
interface BackendConnectionErrorPageProps {
|
||||
errorMessage: string;
|
||||
isRetrying: boolean;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "./MobileNavBar.css";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
@@ -86,7 +85,6 @@ export interface MobileNavBarProps {
|
||||
};
|
||||
onOpenNodes?: () => void;
|
||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||
shellConnectionControl?: ReactNode;
|
||||
}
|
||||
|
||||
function GitHubLogo({ size = 20 }: { size?: number }) {
|
||||
@@ -138,10 +136,8 @@ export function MobileNavBar({
|
||||
experimentalFeatures,
|
||||
onOpenNodes,
|
||||
pluginDashboardViews = [],
|
||||
shellConnectionControl,
|
||||
}: MobileNavBarProps) {
|
||||
const mode = useViewportMode();
|
||||
void shellConnectionControl;
|
||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -26,6 +26,11 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
const saveCurrent = async () => {
|
||||
setError(null);
|
||||
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({
|
||||
id: activeProfile?.id,
|
||||
name: workingName || "Remote Server",
|
||||
@@ -64,9 +69,9 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
<div className="settings-muted">{profile.serverUrl}</div>
|
||||
</div>
|
||||
<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" 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" aria-label={`Edit ${profile.name}`} onClick={() => setDraft(profile)}>Edit</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" aria-label={`Delete ${profile.name}`} onClick={() => void shellApi.deleteProfile(profile.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -78,8 +83,8 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
<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 }))} />
|
||||
<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 }))} />
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<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" role="alert">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -5,7 +5,7 @@ import "./NativeShellOnboardingModal.css";
|
||||
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
|
||||
const url = new URL(serverUrl);
|
||||
if (authToken) {
|
||||
url.searchParams.set("token", authToken);
|
||||
url.searchParams.set("rt", authToken);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const isDesktop = shellState.host === "desktop-shell";
|
||||
|
||||
@@ -61,34 +63,39 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
|
||||
className="btn"
|
||||
onClick={async () => {
|
||||
setError(null);
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await shellApi.startQrScan();
|
||||
setServerUrl(result.serverUrl);
|
||||
setAuthToken(result.authToken ?? "");
|
||||
} catch (scanError) {
|
||||
setError((scanError as Error).message);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}}
|
||||
disabled={scanning}
|
||||
>
|
||||
Scan QR
|
||||
{scanning ? "Scanning…" : "Scan QR"}
|
||||
</button>
|
||||
<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)} />
|
||||
<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" />
|
||||
<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 className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!canSubmit}
|
||||
disabled={!canSubmit || submitting}
|
||||
onClick={async () => {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (isDesktop && mode === "local") {
|
||||
await shellApi.setDesktopMode("local");
|
||||
@@ -115,10 +122,12 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
|
||||
onComplete();
|
||||
} catch (submitError) {
|
||||
setError((submitError as Error).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
{submitting ? "Saving…" : "Continue"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,26 @@ describe("NativeShellConnectionManager", () => {
|
||||
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 () => {
|
||||
const shellApi = createShellApi();
|
||||
render(
|
||||
|
||||
@@ -27,8 +27,37 @@ describe("NativeShellOnboardingModal", () => {
|
||||
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 () => {
|
||||
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 onComplete = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
@@ -63,6 +92,7 @@ describe("NativeShellOnboardingModal", () => {
|
||||
expect(saveProfile).toHaveBeenCalled();
|
||||
expect(setActiveProfile).toHaveBeenCalledWith("p1");
|
||||
expect(window.location.href).toContain("https://fusion.example.com");
|
||||
expect(window.location.href).toContain("rt=abc");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface ShellContextValue {
|
||||
shellApi: FusionShellApi | null;
|
||||
state: ShellConnectionState;
|
||||
ready: boolean;
|
||||
openConnectionManagerSignal: number;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: ShellConnectionState = {
|
||||
@@ -17,12 +18,14 @@ const ShellContext = createContext<ShellContextValue>({
|
||||
shellApi: null,
|
||||
state: DEFAULT_STATE,
|
||||
ready: true,
|
||||
openConnectionManagerSignal: 0,
|
||||
});
|
||||
|
||||
export function ShellProvider({ children }: PropsWithChildren) {
|
||||
const shellApi = useMemo(() => (typeof window !== "undefined" ? window.fusionShell ?? null : null), []);
|
||||
const [state, setState] = useState<ShellConnectionState>(DEFAULT_STATE);
|
||||
const [ready, setReady] = useState(!shellApi);
|
||||
const [openConnectionManagerSignal, setOpenConnectionManagerSignal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shellApi) {
|
||||
@@ -41,13 +44,19 @@ export function ShellProvider({ children }: PropsWithChildren) {
|
||||
setState(nextState);
|
||||
});
|
||||
|
||||
const handleOpenConnectionManager = () => {
|
||||
setOpenConnectionManagerSignal((value) => value + 1);
|
||||
};
|
||||
window.addEventListener("shell:open-connection-manager", handleOpenConnectionManager);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
window.removeEventListener("shell:open-connection-manager", handleOpenConnectionManager);
|
||||
};
|
||||
}, [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 {
|
||||
|
||||
@@ -127,4 +127,16 @@ describe("ipc handlers", () => {
|
||||
expect(onDesktopModeChange).toHaveBeenCalledWith("local");
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,20 @@ function createProfileId(): string {
|
||||
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(
|
||||
settings: Awaited<ReturnType<typeof readShellSettings>>,
|
||||
localServerState?: DesktopLocalServerState,
|
||||
@@ -113,7 +127,7 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
||||
const nextProfile: ShellConnectionProfile = {
|
||||
id: existing?.id ?? profile.id ?? createProfileId(),
|
||||
name: profile.name.trim(),
|
||||
serverUrl: profile.serverUrl.trim().replace(/\/$/, ""),
|
||||
serverUrl: normalizeServerUrl(profile.serverUrl),
|
||||
authToken: profile.authToken ?? null,
|
||||
createdAt: existing?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
|
||||
@@ -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 () => {
|
||||
const { saveShellProfile, setActiveShellProfile, loadShellProfiles, deleteShellProfile } = await import("../plugins/connection-profiles.js");
|
||||
|
||||
|
||||
@@ -53,6 +53,21 @@ describe("MobileNativeShellBridge", () => {
|
||||
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 () => {
|
||||
const { MobileNativeShellBridge } = await import("../plugins/native-shell.js");
|
||||
const bridge = new MobileNativeShellBridge(scanner as never);
|
||||
|
||||
@@ -12,6 +12,11 @@ describe("qr-scanner", () => {
|
||||
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 () => {
|
||||
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 });
|
||||
|
||||
@@ -17,7 +17,17 @@ function createId(): 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 {
|
||||
|
||||
@@ -69,7 +69,9 @@ export class MobileNativeShellBridge implements FusionShellApi {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -27,7 +27,7 @@ function parsePayload(raw: string): QrScanResult {
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const authToken = url.searchParams.get("authToken");
|
||||
const authToken = url.searchParams.get("authToken") ?? url.searchParams.get("rt");
|
||||
return {
|
||||
serverUrl: `${url.protocol}//${url.host}`,
|
||||
authToken,
|
||||
|
||||
Reference in New Issue
Block a user