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:
Fusion
2026-05-08 00:40:12 -07:00
committed by gsxdsm
parent 42b0cf0416
commit 9c484cf19c
20 changed files with 487 additions and 5 deletions

View File

@@ -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

View File

@@ -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

View 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" });
});
});

View File

@@ -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;

View File

@@ -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"

View 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);
}
}

View 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>
);
}

View File

@@ -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();
});
});
});

View File

@@ -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();

View File

@@ -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"));

View File

@@ -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");
});
});

View 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),
};
}
},
};
}

View File

@@ -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):

View File

@@ -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");

View File

@@ -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 () => {

View File

@@ -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),

View File

@@ -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>;

View File

@@ -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.

View File

@@ -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>;
}