feat(FN-3406): add shell context hook plumbing for dashboard

Adds a new `useShellContext` hook with tests to the dashboard, integrated into `App.tsx` and `Header.tsx` to provide shell context plumbing throughout the UI.

Fusion-Task-Id: FN-3406
This commit is contained in:
Fusion
2026-05-07 22:00:41 -07:00
committed by gsxdsm
parent 954ae1078d
commit a6c4ee2b8c
23 changed files with 785 additions and 23 deletions

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { buildMobileShellHandoff } from "../plugins/shell-handoff.js";
describe("buildMobileShellHandoff", () => {
it("builds remote handoff URL for valid active profile", () => {
const result = buildMobileShellHandoff({
host: "mobile-shell",
activeProfileId: "profile_1",
profiles: [
{
id: "profile_1",
name: "Prod",
serverUrl: "https://fusion.example.com/",
authToken: "abc123",
createdAt: "",
updatedAt: "",
lastUsedAt: null,
},
],
});
expect(result.kind).toBe("remote-launch");
if (result.kind !== "remote-launch") {
throw new Error("expected remote-launch");
}
const url = new URL(result.url);
expect(url.searchParams.get("shellKind")).toBe("mobile");
expect(url.searchParams.get("shellMode")).toBe("remote");
expect(url.searchParams.get("profileId")).toBe("profile_1");
expect(url.searchParams.get("serverBaseUrl")).toBe("https://fusion.example.com");
expect(url.searchParams.get("token")).toBe("abc123");
});
it("returns deterministic fallback when no active profile exists", () => {
const result = buildMobileShellHandoff({
host: "mobile-shell",
activeProfileId: null,
profiles: [],
});
expect(result).toEqual({ kind: "fallback", reason: "no-active-profile" });
});
it("returns fallback for invalid server URLs", () => {
const result = buildMobileShellHandoff({
host: "mobile-shell",
activeProfileId: "profile_1",
profiles: [
{
id: "profile_1",
name: "Prod",
serverUrl: "not-a-url",
authToken: null,
createdAt: "",
updatedAt: "",
lastUsedAt: null,
},
],
});
expect(result).toEqual({ kind: "fallback", reason: "invalid-server-url" });
});
});

View File

@@ -22,6 +22,7 @@ export type {
} from "./plugins/push-notifications.js";
export { ShareManager } from "./plugins/share.js";
export { MobileNativeShellBridge } from "./plugins/native-shell.js";
export { buildMobileShellHandoff } from "./plugins/shell-handoff.js";
export { QrScanner, parseQrConnectionPayload } from "./plugins/qr-scanner.js";
export {
loadShellProfiles,
@@ -42,6 +43,8 @@ export type {
ShellConnectionProfile,
ShellConnectionProfileInput,
ShellConnectionState,
MobileShellHandoffResult,
MobileRemoteShellLaunch,
} from "./types.js";
interface LifecycleManager {

View File

@@ -0,0 +1,74 @@
import type {
MobileRemoteShellLaunch,
ShellConnectionProfile,
ShellConnectionState,
MobileShellHandoffResult,
} from "../types.js";
export const SHELL_HANDOFF_QUERY = {
shellKind: "shellKind",
shellMode: "shellMode",
profileId: "profileId",
serverBaseUrl: "serverBaseUrl",
serverLabel: "serverLabel",
token: "token",
canOpenConnectionManager: "shellCanOpenConnectionManager",
} as const;
function normalizeServerBaseUrl(input: string): string | null {
try {
const parsed = new URL(input);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return null;
}
return parsed.toString().replace(/\/$/, "");
} catch {
return null;
}
}
function findActiveProfile(state: ShellConnectionState): ShellConnectionProfile | null {
if (!state.activeProfileId) {
return null;
}
return state.profiles.find((profile) => profile.id === state.activeProfileId) ?? null;
}
export function buildMobileShellHandoff(state: ShellConnectionState): MobileShellHandoffResult {
const activeProfile = findActiveProfile(state);
if (!state.activeProfileId) {
return { kind: "fallback", reason: "no-active-profile" };
}
if (!activeProfile) {
return { kind: "fallback", reason: "missing-profile" };
}
const serverBaseUrl = normalizeServerBaseUrl(activeProfile.serverUrl);
if (!serverBaseUrl) {
return { kind: "fallback", reason: "invalid-server-url" };
}
const launch: MobileRemoteShellLaunch = {
shellKind: "mobile",
shellMode: "remote",
profileId: activeProfile.id,
serverBaseUrl,
...(activeProfile.name ? { serverLabel: activeProfile.name } : {}),
...(activeProfile.authToken ? { token: activeProfile.authToken } : {}),
};
const url = new URL(serverBaseUrl);
url.searchParams.set(SHELL_HANDOFF_QUERY.shellKind, launch.shellKind);
url.searchParams.set(SHELL_HANDOFF_QUERY.shellMode, launch.shellMode);
url.searchParams.set(SHELL_HANDOFF_QUERY.profileId, launch.profileId);
url.searchParams.set(SHELL_HANDOFF_QUERY.serverBaseUrl, launch.serverBaseUrl);
if (launch.serverLabel) {
url.searchParams.set(SHELL_HANDOFF_QUERY.serverLabel, launch.serverLabel);
}
if (launch.token) {
url.searchParams.set(SHELL_HANDOFF_QUERY.token, launch.token);
}
url.searchParams.set(SHELL_HANDOFF_QUERY.canOpenConnectionManager, "1");
return { kind: "remote-launch", launch, url: url.toString() };
}

View File

@@ -38,6 +38,19 @@ export interface ShellConnectionState {
};
}
export interface MobileRemoteShellLaunch {
shellKind: "mobile";
shellMode: "remote";
profileId: string;
serverBaseUrl: string;
serverLabel?: string;
token?: string;
}
export type MobileShellHandoffResult =
| { kind: "remote-launch"; url: string; launch: MobileRemoteShellLaunch }
| { kind: "fallback"; reason: "no-active-profile" | "missing-profile" | "invalid-server-url" };
export interface FusionShellApi {
getState(): Promise<ShellConnectionState>;
listProfiles(): Promise<ShellConnectionProfile[]>;