docs(FN-3408): document shell connection chrome contracts
- Add architecture notes describing shell connection chrome contracts - Expand dashboard README with shell chrome expectations and integration details - Add desktop README note aligning desktop shell behavior with documented contracts Fusion-Task-Id: FN-3408
This commit is contained in:
@@ -15,6 +15,13 @@ When running inside Fusion mobile or desktop shells, the dashboard uses a host-n
|
||||
|
||||
The shared dashboard must use `window.fusionShell` for shell connectivity concerns (not direct Electron or Capacitor globals).
|
||||
|
||||
For dashboard chrome, use the centralized helper/component path:
|
||||
- `app/shell-native.ts` (`getShellConnectionNativeResult`) for host-aware capability + non-sensitive metadata resolution
|
||||
- `app/components/ShellConnectionStatus.tsx` for rendering shell kind/mode/connection summary and action labels
|
||||
- App-level wiring should pass derived props into `Header` / `MobileNavBar`; downstream components should not read `window` bridges directly
|
||||
|
||||
Desktop connection-management actions must go through `window.fusionAPI.openConnectionManager()` (wrapped by `shell-native.ts`), not ad-hoc renderer IPC calls.
|
||||
|
||||
## Canonical dashboard host-context contract
|
||||
|
||||
Dashboard host detection is centralized in `app/shell-host.ts` and exposed to React via `ShellHostProvider` (`app/context/ShellHostContext.tsx`).
|
||||
@@ -32,6 +39,11 @@ Bootstrap priority is fixed:
|
||||
|
||||
Shell launch query params are removed after bootstrap. UI components should consume `useShellHostContext()` instead of reading `window` globals directly.
|
||||
|
||||
Keep host and node concerns separate:
|
||||
- `ShellHostContext.mode` (`local` / `remote`) describes how native shell sessions reached this dashboard instance.
|
||||
- `NodeContext.isRemote` describes browsing a remote mesh node from within the dashboard.
|
||||
These concepts can coexist and should not replace each other.
|
||||
|
||||
## Features
|
||||
|
||||
### Planning Mode
|
||||
|
||||
@@ -60,7 +60,8 @@ import { ShellHostProvider, useShellHostContext } from "./context/ShellHostConte
|
||||
import { useShellConnection } from "./hooks/useShellConnection";
|
||||
import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal";
|
||||
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
|
||||
import { NativeShellConnectionStatus } from "./components/NativeShellConnectionStatus";
|
||||
import { ShellConnectionStatus } from "./components/ShellConnectionStatus";
|
||||
import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native";
|
||||
import type { AiSessionSummary } from "./api";
|
||||
import { fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps } from "./api";
|
||||
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
|
||||
@@ -908,6 +909,7 @@ function AppInner() {
|
||||
|
||||
const [shellOnboardingComplete, setShellOnboardingComplete] = useState(false);
|
||||
const [shellConnectionManagerOpen, setShellConnectionManagerOpen] = useState(false);
|
||||
const [shellConnectionStatus, setShellConnectionStatus] = useState<ShellConnectionNativeResult | null>(null);
|
||||
|
||||
const requiresShellOnboarding = requiresNativeShellOnboarding(shellState, shellReady, shellOnboardingComplete);
|
||||
|
||||
@@ -918,6 +920,19 @@ function AppInner() {
|
||||
setShellConnectionManagerOpen(true);
|
||||
}, [shellApi, openConnectionManagerSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getShellConnectionNativeResult(shellHost.host).then((result) => {
|
||||
if (!cancelled) {
|
||||
setShellConnectionStatus(result);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [shellHost.host, shellState.activeProfileId, shellState.desktopMode, shellState.host, shellState.profiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shellState.host !== "desktop-shell") {
|
||||
return;
|
||||
@@ -1382,9 +1397,14 @@ function AppInner() {
|
||||
researchView: researchEnabled,
|
||||
}}
|
||||
pluginDashboardViews={pluginDashboardViews}
|
||||
shellConnectionControl={shellApi && shellState.host !== "web" ? (
|
||||
<NativeShellConnectionStatus state={shellState} onManage={() => setShellConnectionManagerOpen(true)} />
|
||||
) : undefined}
|
||||
shellConnectionControl={
|
||||
!isMobile && shellConnectionStatus ? (
|
||||
<ShellConnectionStatus
|
||||
status={shellConnectionStatus}
|
||||
onError={(message) => addToast(message, "error")}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
|
||||
<SessionNotificationBanner
|
||||
@@ -1484,6 +1504,14 @@ function AppInner() {
|
||||
nodesView: nodesEnabled,
|
||||
}}
|
||||
pluginDashboardViews={pluginDashboardViews}
|
||||
shellConnectionControl={
|
||||
isMobile && shellConnectionStatus ? (
|
||||
<ShellConnectionStatus
|
||||
status={shellConnectionStatus}
|
||||
onError={(message) => addToast(message, "error")}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "evals" && taskView !== "devserver" && taskView !== "dev-server" && taskView !== "graph" && !isPluginViewId(taskView) && (
|
||||
<QuickChatFAB
|
||||
|
||||
70
packages/dashboard/app/__tests__/shell-native.test.ts
Normal file
70
packages/dashboard/app/__tests__/shell-native.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getShellConnectionNativeResult } from "../shell-native";
|
||||
|
||||
describe("shell-native", () => {
|
||||
it("returns unsupported browser fallback", async () => {
|
||||
const result = await getShellConnectionNativeResult({ kind: "browser" }, window);
|
||||
expect(result.hostKind).toBe("browser");
|
||||
expect(result.available).toBe(false);
|
||||
await expect(result.openConnectionManager()).resolves.toEqual({ ok: false, reason: "unsupported" });
|
||||
});
|
||||
|
||||
it("returns unsupported for mobile shell without bridge", async () => {
|
||||
const result = await getShellConnectionNativeResult({ kind: "mobile-shell", mode: "remote" }, window);
|
||||
expect(result.available).toBe(false);
|
||||
await expect(result.openConnectionManager()).resolves.toEqual({ ok: false, reason: "unsupported" });
|
||||
});
|
||||
|
||||
it("uses desktop fusionAPI connection manager", async () => {
|
||||
const openConnectionManager = vi.fn(async () => undefined);
|
||||
const target = {
|
||||
...window,
|
||||
fusionAPI: { openConnectionManager },
|
||||
} as Window & typeof globalThis & { fusionAPI: { openConnectionManager: () => Promise<void> } };
|
||||
|
||||
const result = await getShellConnectionNativeResult({ kind: "desktop-shell", mode: "local" }, target);
|
||||
expect(result.available).toBe(true);
|
||||
await expect(result.openConnectionManager()).resolves.toEqual({ ok: true });
|
||||
expect(openConnectionManager).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses mobile fusionShell capability and extracts metadata", async () => {
|
||||
const openConnectionManager = vi.fn(async () => undefined);
|
||||
const target = {
|
||||
...window,
|
||||
fusionShell: {
|
||||
openConnectionManager,
|
||||
getState: vi.fn(async () => ({
|
||||
host: "mobile-shell",
|
||||
activeProfileId: "p1",
|
||||
profiles: [{ id: "p1", name: "Remote 1", serverUrl: "https://fusion.example.com/root", createdAt: "", updatedAt: "" }],
|
||||
})),
|
||||
},
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
const result = await getShellConnectionNativeResult(
|
||||
{ kind: "mobile-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com/root" },
|
||||
target,
|
||||
);
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.profileId).toBe("p1");
|
||||
expect(result.profileLabel).toBe("Remote 1");
|
||||
expect(result.serverOrigin).toBe("https://fusion.example.com");
|
||||
await expect(result.openConnectionManager()).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("surfaces invocation failures", async () => {
|
||||
const target = {
|
||||
...window,
|
||||
fusionAPI: {
|
||||
openConnectionManager: vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
},
|
||||
} as Window & typeof globalThis;
|
||||
|
||||
const result = await getShellConnectionNativeResult({ kind: "desktop-shell", mode: "remote" }, target);
|
||||
await expect(result.openConnectionManager()).resolves.toEqual({ ok: false, reason: "failed", error: "boom" });
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,15 @@
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mobile-more-shell-connection {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.mobile-more-shell-connection .shell-connection-status {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mobile-more-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./MobileNavBar.css";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
@@ -86,6 +86,7 @@ export interface MobileNavBarProps {
|
||||
};
|
||||
onOpenNodes?: () => void;
|
||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||
shellConnectionControl?: ReactNode;
|
||||
}
|
||||
|
||||
function GitHubLogo({ size = 20 }: { size?: number }) {
|
||||
@@ -138,6 +139,7 @@ export function MobileNavBar({
|
||||
experimentalFeatures,
|
||||
onOpenNodes,
|
||||
pluginDashboardViews = [],
|
||||
shellConnectionControl,
|
||||
}: MobileNavBarProps) {
|
||||
const mode = useViewportMode();
|
||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||
@@ -391,6 +393,12 @@ export function MobileNavBar({
|
||||
<div className="mobile-more-sheet-handle" />
|
||||
<div className="mobile-more-sheet-title">Navigate</div>
|
||||
|
||||
{shellConnectionControl ? (
|
||||
<div className="mobile-more-shell-connection" data-testid="mobile-more-shell-connection">
|
||||
{shellConnectionControl}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
|
||||
34
packages/dashboard/app/components/ShellConnectionStatus.css
Normal file
34
packages/dashboard/app/components/ShellConnectionStatus.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.shell-connection-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.shell-connection-status__kind {
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.shell-connection-status__summary {
|
||||
color: var(--text);
|
||||
max-width: calc(var(--space-2xl) * 8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shell-connection-status__action {
|
||||
color: var(--color-info);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shell-connection-status {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.shell-connection-status__summary {
|
||||
max-width: calc(var(--space-2xl) * 5);
|
||||
}
|
||||
}
|
||||
55
packages/dashboard/app/components/ShellConnectionStatus.tsx
Normal file
55
packages/dashboard/app/components/ShellConnectionStatus.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ShellConnectionNativeResult } from "../shell-native";
|
||||
import "./ShellConnectionStatus.css";
|
||||
|
||||
export interface ShellConnectionStatusProps {
|
||||
status: ShellConnectionNativeResult;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
function buildSummary(status: ShellConnectionNativeResult): { title: string; actionLabel: string; dotClassName: string } {
|
||||
if (status.hostKind === "desktop-shell" && status.mode === "local") {
|
||||
return { title: "Desktop local mode", actionLabel: "Switch server", dotClassName: "status-dot status-dot--online" };
|
||||
}
|
||||
|
||||
const profileText = status.profileLabel ?? status.profileId;
|
||||
const originText = status.serverOrigin;
|
||||
const summary = profileText && originText ? `${profileText} · ${originText}` : profileText ?? originText;
|
||||
|
||||
if (status.mode === "remote") {
|
||||
return {
|
||||
title: summary ?? "Connection info unavailable",
|
||||
actionLabel: status.hostKind === "desktop-shell" ? "Switch server" : "Manage connections",
|
||||
dotClassName: summary ? "status-dot status-dot--online" : "status-dot status-dot--pending",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: summary ?? "Connection info unavailable",
|
||||
actionLabel: "Manage connections",
|
||||
dotClassName: summary ? "status-dot status-dot--online" : "status-dot status-dot--pending",
|
||||
};
|
||||
}
|
||||
|
||||
export function ShellConnectionStatus({ status, onError }: ShellConnectionStatusProps) {
|
||||
if (status.hostKind === "browser" || !status.available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const view = buildSummary(status);
|
||||
const handleClick = useCallback(async () => {
|
||||
const result = await status.openConnectionManager();
|
||||
if (!result.ok && result.reason === "failed") {
|
||||
onError?.(result.error ?? "Failed to open connection manager");
|
||||
}
|
||||
}, [onError, status]);
|
||||
|
||||
return (
|
||||
<button type="button" className="btn shell-connection-status" onClick={() => void handleClick()} data-testid="shell-connection-status-button">
|
||||
<span className={view.dotClassName} aria-hidden="true" />
|
||||
<span className="shell-connection-status__kind">{status.hostKind === "desktop-shell" ? "Desktop" : "Mobile"}</span>
|
||||
<span className="shell-connection-status__summary" title={view.title}>{view.title}</span>
|
||||
<span className="shell-connection-status__action">{view.actionLabel}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -174,6 +174,12 @@ const mockShellConnectionState = {
|
||||
localServer: null,
|
||||
};
|
||||
|
||||
const mockGetShellConnectionNativeResult = vi.fn(async () => ({
|
||||
hostKind: "browser" as const,
|
||||
available: false,
|
||||
openConnectionManager: async () => ({ ok: false as const, reason: "unsupported" as const }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useShellConnection", () => ({
|
||||
useShellConnection: vi.fn(() => ({
|
||||
shellApi: null,
|
||||
@@ -183,6 +189,10 @@ vi.mock("../../hooks/useShellConnection", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../shell-native", () => ({
|
||||
getShellConnectionNativeResult: (...args: unknown[]) => mockGetShellConnectionNativeResult(...args),
|
||||
}));
|
||||
|
||||
// Mock model-onboarding-state
|
||||
const mockIsOnboardingResumable = vi.fn();
|
||||
const mockGetOnboardingResumeStep = vi.fn();
|
||||
@@ -3639,3 +3649,59 @@ describe("App board branch filters", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("App shell connection status plumbing", () => {
|
||||
it("loads shell connection status for native shell host", async () => {
|
||||
mockShellHostContextValue.host = { kind: "desktop-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" };
|
||||
mockGetShellConnectionNativeResult.mockResolvedValueOnce({
|
||||
hostKind: "desktop-shell",
|
||||
available: true,
|
||||
mode: "remote",
|
||||
profileLabel: "Prod",
|
||||
serverOrigin: "https://fusion.example.com",
|
||||
openConnectionManager: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
|
||||
expect(screen.getByTestId("shell-connection-status-button")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render shell connection status in browser mode", async () => {
|
||||
mockShellHostContextValue.host = { kind: "browser" };
|
||||
mockGetShellConnectionNativeResult.mockResolvedValueOnce({
|
||||
hostKind: "browser",
|
||||
available: false,
|
||||
openConnectionManager: async () => ({ ok: false, reason: "unsupported" }),
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
|
||||
});
|
||||
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders shell connection status for mobile shell host", async () => {
|
||||
mockShellHostContextValue.host = { kind: "mobile-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" };
|
||||
mockGetShellConnectionNativeResult.mockResolvedValueOnce({
|
||||
hostKind: "mobile-shell",
|
||||
available: true,
|
||||
mode: "remote",
|
||||
profileLabel: "Mobile",
|
||||
serverOrigin: "https://fusion.example.com",
|
||||
openConnectionManager: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
|
||||
expect(screen.getByTestId("shell-connection-status-button")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,16 @@ describe("Header", () => {
|
||||
expect(container.querySelector("header.header")?.getAttribute("data-shell-kind")).toBe("desktop-shell");
|
||||
});
|
||||
|
||||
it("renders shell connection control when provided", () => {
|
||||
renderHeader({ shellConnectionControl: <button type="button">Manage connections</button> });
|
||||
expect(screen.getByRole("button", { name: "Manage connections" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render shell connection control when omitted", () => {
|
||||
const { container } = renderHeader({ shellConnectionControl: undefined });
|
||||
expect(container.querySelector(".shell-connection-status")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders action buttons", () => {
|
||||
renderHeader();
|
||||
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
|
||||
|
||||
@@ -353,6 +353,19 @@ describe("MobileNavBar", () => {
|
||||
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders shell connection control in More sheet when provided", () => {
|
||||
render(
|
||||
<MobileNavBar
|
||||
{...createDefaultProps()}
|
||||
shellConnectionControl={<button type="button">Manage connections</button>}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
|
||||
expect(screen.getByTestId("mobile-more-shell-connection")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Manage connections" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sheet contains expected navigation items including activity log", () => {
|
||||
render(<MobileNavBar {...createDefaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ShellConnectionStatus } from "../ShellConnectionStatus";
|
||||
import type { ShellConnectionNativeResult } from "../../shell-native";
|
||||
|
||||
function makeStatus(overrides: Partial<ShellConnectionNativeResult> = {}): ShellConnectionNativeResult {
|
||||
return {
|
||||
hostKind: "desktop-shell",
|
||||
available: true,
|
||||
mode: "remote",
|
||||
profileId: "p1",
|
||||
profileLabel: "Prod",
|
||||
serverOrigin: "https://fusion.example.com",
|
||||
openConnectionManager: vi.fn(async () => ({ ok: true })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ShellConnectionStatus", () => {
|
||||
it("renders local desktop mode", () => {
|
||||
render(<ShellConnectionStatus status={makeStatus({ mode: "local" })} />);
|
||||
expect(screen.getByText("Desktop local mode")).toBeInTheDocument();
|
||||
expect(screen.getByText("Switch server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders remote desktop mode summary", () => {
|
||||
render(<ShellConnectionStatus status={makeStatus()} />);
|
||||
expect(screen.getByText("Desktop")).toBeInTheDocument();
|
||||
expect(screen.getByText("Prod · https://fusion.example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Switch server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders remote mobile mode summary", () => {
|
||||
render(
|
||||
<ShellConnectionStatus
|
||||
status={makeStatus({ hostKind: "mobile-shell", mode: "remote", profileLabel: "Tablet", serverOrigin: "https://remote.example.com" })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Mobile")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tablet · https://remote.example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Manage connections")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides in browser/unsupported mode", () => {
|
||||
const { rerender } = render(<ShellConnectionStatus status={makeStatus({ hostKind: "browser", available: false })} />);
|
||||
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
||||
|
||||
rerender(<ShellConnectionStatus status={makeStatus({ hostKind: "mobile-shell", available: false })} />);
|
||||
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes action and reports failures", async () => {
|
||||
const onError = vi.fn();
|
||||
const openConnectionManager = vi.fn(async () => ({ ok: false as const, reason: "failed" as const, error: "no bridge" }));
|
||||
render(<ShellConnectionStatus status={makeStatus({ openConnectionManager })} onError={onError} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("shell-connection-status-button"));
|
||||
await waitFor(() => expect(openConnectionManager).toHaveBeenCalledTimes(1));
|
||||
expect(onError).toHaveBeenCalledWith("no bridge");
|
||||
});
|
||||
});
|
||||
91
packages/dashboard/app/shell-native.ts
Normal file
91
packages/dashboard/app/shell-native.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { ShellHostContext } from "./shell-host";
|
||||
import type { FusionShellApi, ShellConnectionProfile, ShellConnectionState } from "./types/native-shell";
|
||||
|
||||
export interface ShellConnectionNativeResult {
|
||||
hostKind: ShellHostContext["kind"];
|
||||
available: boolean;
|
||||
mode?: "local" | "remote";
|
||||
profileId?: string;
|
||||
profileLabel?: string;
|
||||
serverOrigin?: string;
|
||||
openConnectionManager: () => Promise<{ ok: true } | { ok: false; reason: "unsupported" | "failed"; error?: string }>;
|
||||
}
|
||||
|
||||
type FusionApiBridge = {
|
||||
openConnectionManager?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function toOrigin(serverUrl?: string): string | undefined {
|
||||
if (!serverUrl) return undefined;
|
||||
try {
|
||||
return new URL(serverUrl).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProfile(
|
||||
state: Pick<ShellConnectionState, "activeProfileId" | "profiles">,
|
||||
host: ShellHostContext,
|
||||
): ShellConnectionProfile | null {
|
||||
const active = state.profiles.find((profile) => profile.id === state.activeProfileId) ?? null;
|
||||
if (active) return active;
|
||||
if (host.kind === "browser" || !host.connectionId) return null;
|
||||
return state.profiles.find((profile) => profile.id === host.connectionId) ?? null;
|
||||
}
|
||||
|
||||
async function readShellState(shellApi?: Pick<FusionShellApi, "getState">): Promise<ShellConnectionState | null> {
|
||||
if (!shellApi?.getState) return null;
|
||||
try {
|
||||
return await shellApi.getState();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getShellConnectionNativeResult(
|
||||
host: ShellHostContext,
|
||||
target: Window & typeof globalThis = window,
|
||||
): Promise<ShellConnectionNativeResult> {
|
||||
const fusionApi = (target as Window & { fusionAPI?: FusionApiBridge }).fusionAPI;
|
||||
const shellApi = (target as Window & { fusionShell?: FusionShellApi }).fusionShell;
|
||||
const shellState = await readShellState(shellApi);
|
||||
|
||||
const profile = shellState ? resolveProfile(shellState, host) : null;
|
||||
const mode = host.kind === "browser" ? undefined : host.mode ?? shellState?.desktopMode;
|
||||
const profileId = profile?.id ?? (host.kind === "browser" ? undefined : host.connectionId);
|
||||
const profileLabel = profile?.name;
|
||||
const serverOrigin = toOrigin(profile?.serverUrl ?? (host.kind === "browser" ? undefined : host.serverUrl));
|
||||
|
||||
const desktopSupported = host.kind === "desktop-shell" && typeof fusionApi?.openConnectionManager === "function";
|
||||
const mobileSupported = host.kind === "mobile-shell" && typeof shellApi?.openConnectionManager === "function";
|
||||
const available = desktopSupported || mobileSupported;
|
||||
|
||||
return {
|
||||
hostKind: host.kind,
|
||||
available,
|
||||
...(mode ? { mode } : {}),
|
||||
...(profileId ? { profileId } : {}),
|
||||
...(profileLabel ? { profileLabel } : {}),
|
||||
...(serverOrigin ? { serverOrigin } : {}),
|
||||
openConnectionManager: async () => {
|
||||
try {
|
||||
if (desktopSupported) {
|
||||
await fusionApi.openConnectionManager?.();
|
||||
return { ok: true };
|
||||
}
|
||||
if (mobileSupported) {
|
||||
await shellApi.openConnectionManager();
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: "unsupported" };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -174,6 +174,7 @@ Desktop local mode uses an in-process runtime manager (`src/local-runtime.ts`) t
|
||||
- App/system: `getSystemInfo()`, `checkForUpdates()`, `getServerPort()`
|
||||
- Desktop runtime: `getDesktopRuntimeStatus()`, `startDesktopLocalRuntime()`, `stopDesktopLocalRuntime()`
|
||||
- Desktop launch mode: `getDesktopLaunchMode()`, `setDesktopLaunchMode(mode)`
|
||||
- Native shell management: `openConnectionManager()` (invokes `shell:openConnectionManager`)
|
||||
- Tray: `updateTrayStatus(status)`
|
||||
- Native dialogs: `showExportDialog()`, `showImportDialog()`
|
||||
- Event subscriptions (return unsubscribe functions):
|
||||
|
||||
@@ -131,6 +131,7 @@ describe("ipc handlers", () => {
|
||||
expect(channels.has("desktopLaunchMode:getContext")).toBe(true);
|
||||
expect(channels.has("desktopLaunchMode:setMode")).toBe(true);
|
||||
expect(channels.has("platform:get")).toBe(true);
|
||||
expect(channels.has("shell:openConnectionManager")).toBe(true);
|
||||
});
|
||||
|
||||
it("shell:getState returns desktop shell state", async () => {
|
||||
@@ -181,6 +182,13 @@ describe("ipc handlers", () => {
|
||||
await expect(mocks.ipcHandlers.get("desktopRuntime:stopLocal")?.({})).resolves.toEqual({ source: "embedded-local", state: "running", port: 9999 });
|
||||
});
|
||||
|
||||
it("shell:openConnectionManager notifies renderer", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
const result = mocks.ipcHandlers.get("shell:openConnectionManager")?.({});
|
||||
expect(result).toBeUndefined();
|
||||
expect(window.webContents.send).toHaveBeenCalledWith("shell:open-connection-manager");
|
||||
});
|
||||
|
||||
it("shell:saveProfile persists the helper-generated profile", async () => {
|
||||
await registerHandlers();
|
||||
const handler = mocks.ipcHandlers.get("shell:saveProfile");
|
||||
|
||||
@@ -56,15 +56,18 @@ describe("preload", () => {
|
||||
getDesktopLaunchMode: () => Promise<string>;
|
||||
getDesktopLaunchContext: () => Promise<unknown>;
|
||||
setDesktopLaunchMode: (mode: "choose" | "local" | "remote") => Promise<string>;
|
||||
openConnectionManager: () => Promise<void>;
|
||||
}>("electronAPI");
|
||||
|
||||
await api?.getDesktopLaunchMode();
|
||||
await api?.getDesktopLaunchContext();
|
||||
await api?.setDesktopLaunchMode("local");
|
||||
await api?.openConnectionManager();
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:getMode");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:getContext");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:setMode", "local");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:openConnectionManager");
|
||||
});
|
||||
|
||||
it("fusionShell subscribes and unsubscribes state listener", async () => {
|
||||
|
||||
@@ -59,6 +59,7 @@ const electronApi = {
|
||||
ipcRenderer.invoke("desktopLaunchMode:setMode", mode),
|
||||
getDesktopLaunchContext: (): Promise<{ mode: "remote"; profileId: string; serverBaseUrl: string; serverLabel?: string; authToken?: string } | null> =>
|
||||
ipcRenderer.invoke("desktopLaunchMode:getContext"),
|
||||
openConnectionManager: (): Promise<void> => ipcRenderer.invoke("shell:openConnectionManager"),
|
||||
|
||||
// Tray status
|
||||
updateTrayStatus: (status: string): Promise<void> => ipcRenderer.invoke("tray:updateStatus", status),
|
||||
|
||||
1
packages/desktop/src/types.d.ts
vendored
1
packages/desktop/src/types.d.ts
vendored
@@ -37,6 +37,7 @@ export interface FusionAPI {
|
||||
getDesktopLaunchMode(): Promise<"choose" | "local" | "remote">;
|
||||
setDesktopLaunchMode(mode: "choose" | "local" | "remote"): Promise<"choose" | "local" | "remote">;
|
||||
getDesktopLaunchContext(): Promise<{ mode: "remote"; profileId: string; serverBaseUrl: string; serverLabel?: string; authToken?: string } | null>;
|
||||
openConnectionManager(): Promise<void>;
|
||||
|
||||
// Tray status
|
||||
updateTrayStatus(status: string): Promise<void>;
|
||||
|
||||
@@ -10,6 +10,7 @@ Mobile uses a shell-level onboarding flow for first-run connection setup before
|
||||
- **Active-profile fallback:** deleting the active profile automatically promotes the first remaining profile; deleting the last profile resets to an empty state (`activeProfileId: null`, `profiles: []`) so onboarding/manager recovery can reopen cleanly.
|
||||
- **Storage boundary:** profile/mode state is stored only in mobile shell-local storage (via native plugin wrappers), not in Fusion project settings/local dashboard project storage.
|
||||
- **Bridge contract:** mobile exposes `window.fusionShell` (`getState`, `listProfiles`, `saveProfile`, `deleteProfile`, `setActiveProfile`, `startQrScan`, `openConnectionManager`, `subscribe`) so shared dashboard code can run host-neutrally.
|
||||
- **Dashboard-safe capability contract:** shared dashboard helpers should consume the typed `MobileShellDashboardBridge` subset (`getState?`, `openConnectionManager?`). If either function is missing at runtime, treat connection-management as unsupported instead of throwing.
|
||||
|
||||
Native wrappers are isolated under `src/plugins/native-shell.ts`, `src/plugins/connection-profiles.ts`, and `src/plugins/qr-scanner.ts` so dashboard code never calls vendor-specific APIs directly.
|
||||
|
||||
|
||||
@@ -62,3 +62,8 @@ export interface FusionShellApi {
|
||||
openConnectionManager(): Promise<void>;
|
||||
subscribe(listener: (state: ShellConnectionState) => void): () => void;
|
||||
}
|
||||
|
||||
export interface MobileShellDashboardBridge {
|
||||
getState?: () => Promise<ShellConnectionState>;
|
||||
openConnectionManager?: () => Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user