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";
|
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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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 {
|
interface BackendConnectionErrorPageProps {
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
isRetrying: boolean;
|
isRetrying: boolean;
|
||||||
|
|||||||
@@ -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>>({});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user