feat(FN-3407): normalize shell host bootstrap
Introduces a canonical shell host context and normalized bootstrap flow for the dashboard, extracting the host initialization logic into a dedicated `shell-host.ts` module with a `ShellHostContext` provider; updates `App.tsx` and `Header` to use the new context, stabilizes related tests, and documen Fusion-Task-Id: FN-3407
This commit is contained in:
@@ -15,6 +15,23 @@ 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).
|
||||
|
||||
## 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`).
|
||||
|
||||
Canonical contract:
|
||||
- `{ kind: "browser" }`
|
||||
- `{ kind: "desktop-shell", mode?, connectionId?, serverUrl?, canOpenConnectionManager? }`
|
||||
- `{ kind: "mobile-shell", mode?, connectionId?, serverUrl?, canOpenConnectionManager? }`
|
||||
|
||||
Bootstrap priority is fixed:
|
||||
1. explicit bootstrapped global handoff (`__FUSION_SHELL_HOST_CONTEXT__` / compatibility aliases)
|
||||
2. shell handoff query params
|
||||
3. desktop fallback via `window.fusionAPI` presence
|
||||
4. browser fallback
|
||||
|
||||
Shell launch query params are removed after bootstrap. UI components should consume `useShellHostContext()` instead of reading `window` globals directly.
|
||||
|
||||
## Features
|
||||
|
||||
### Planning Mode
|
||||
|
||||
@@ -56,8 +56,8 @@ import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import { ShellProvider } from "./context/ShellContext";
|
||||
import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext";
|
||||
import { useShellConnection } from "./hooks/useShellConnection";
|
||||
import { useShellContext as useLaunchShellContext } from "./hooks/useShellContext";
|
||||
import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal";
|
||||
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
|
||||
import { NativeShellConnectionStatus } from "./components/NativeShellConnectionStatus";
|
||||
@@ -154,8 +154,7 @@ export function requiresNativeShellOnboarding(
|
||||
function AppInner() {
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
const { shellApi, state: shellState, ready: shellReady, openConnectionManagerSignal } = useShellConnection();
|
||||
const { shellContext } = useLaunchShellContext();
|
||||
const isElectron = shellContext?.shellKind === "desktop";
|
||||
const shellHost = useShellHostContext();
|
||||
|
||||
// Warm lazy view chunks during browser idle so first navigation is instant.
|
||||
useEffect(() => {
|
||||
@@ -1316,8 +1315,7 @@ function AppInner() {
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
isElectron={isElectron}
|
||||
shellContext={shellContext}
|
||||
shellHost={shellHost.host}
|
||||
onOpenSettings={openSettingsWithNav}
|
||||
onOpenGitHubImport={openGitHubImportWithNav}
|
||||
onOpenPlanning={openPlanningWithNav}
|
||||
@@ -1547,13 +1545,15 @@ function AppInner() {
|
||||
export function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ShellProvider>
|
||||
<NodeProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<AppInner />
|
||||
</ConfirmDialogProvider>
|
||||
</NodeProvider>
|
||||
</ShellProvider>
|
||||
<ShellHostProvider>
|
||||
<ShellProvider>
|
||||
<NodeProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<AppInner />
|
||||
</ConfirmDialogProvider>
|
||||
</NodeProvider>
|
||||
</ShellProvider>
|
||||
</ShellHostProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
65
packages/dashboard/app/__tests__/shell-host.test.ts
Normal file
65
packages/dashboard/app/__tests__/shell-host.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import {
|
||||
__resetShellHostContextForTests,
|
||||
bootstrapShellHostContext,
|
||||
detectShellHostContext,
|
||||
} from "../shell-host";
|
||||
|
||||
describe("shell-host", () => {
|
||||
beforeEach(() => {
|
||||
__resetShellHostContextForTests();
|
||||
window.history.replaceState({}, "", "/");
|
||||
delete (window as Window & { fusionAPI?: unknown }).fusionAPI;
|
||||
delete (window as Window & Record<string, unknown>).__FUSION_SHELL_HOST_CONTEXT__;
|
||||
});
|
||||
|
||||
it("falls back to browser when no shell signals are present", () => {
|
||||
expect(detectShellHostContext()).toEqual({ kind: "browser" });
|
||||
});
|
||||
|
||||
it("detects desktop shell via fusionAPI fallback", () => {
|
||||
(window as Window & { fusionAPI?: unknown }).fusionAPI = {};
|
||||
expect(detectShellHostContext()).toEqual({ kind: "desktop-shell" });
|
||||
});
|
||||
|
||||
it("normalizes explicit global handoff", () => {
|
||||
(window as Window & Record<string, unknown>).__FUSION_SHELL_HOST_CONTEXT__ = {
|
||||
kind: "mobile-shell",
|
||||
mode: "remote",
|
||||
connectionId: "conn-1",
|
||||
serverUrl: "https://fusion.example.com/",
|
||||
canOpenConnectionManager: true,
|
||||
};
|
||||
|
||||
expect(detectShellHostContext()).toEqual({
|
||||
kind: "mobile-shell",
|
||||
mode: "remote",
|
||||
connectionId: "conn-1",
|
||||
serverUrl: "https://fusion.example.com",
|
||||
canOpenConnectionManager: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps FN-3406 legacy query params into canonical contract", () => {
|
||||
window.history.replaceState({}, "", "/?shellKind=desktop&shellMode=remote&profileId=p1&serverBaseUrl=https%3A%2F%2Fremote.example.com%2F&shellCanOpenConnectionManager=1");
|
||||
|
||||
expect(detectShellHostContext()).toEqual({
|
||||
kind: "desktop-shell",
|
||||
mode: "remote",
|
||||
connectionId: "p1",
|
||||
serverUrl: "https://remote.example.com",
|
||||
canOpenConnectionManager: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles malformed handoff values without crashing", () => {
|
||||
window.history.replaceState({}, "", "/?shellKind=mobile&shellMode=invalid&serverBaseUrl=notaurl");
|
||||
expect(detectShellHostContext()).toEqual({ kind: "mobile-shell" });
|
||||
});
|
||||
|
||||
it("strips shell launch params from URL at bootstrap", () => {
|
||||
window.history.replaceState({}, "", "/dashboard?view=board&shellKind=desktop&shellMode=remote&profileId=p1#section");
|
||||
bootstrapShellHostContext();
|
||||
expect(window.location.pathname + window.location.search + window.location.hash).toBe("/dashboard?view=board#section");
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import type { TaskView } from "../hooks/useViewState";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||
import type { ShellContext as LaunchShellContext } from "../shell-context";
|
||||
import type { ShellHostContext } from "../shell-host";
|
||||
|
||||
export { useViewportMode };
|
||||
|
||||
@@ -225,7 +225,7 @@ export interface HeaderProps {
|
||||
onSelectProject?: (project: ProjectInfo) => void;
|
||||
onViewAllProjects?: () => void;
|
||||
projectId?: string;
|
||||
isElectron?: boolean;
|
||||
shellHost?: ShellHostContext;
|
||||
/** When true, the mobile bottom nav bar handles primary navigation and header nav controls are hidden. */
|
||||
mobileNavEnabled?: boolean;
|
||||
/** Available nodes for the node selector */
|
||||
@@ -240,7 +240,6 @@ export interface HeaderProps {
|
||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
|
||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||
shellConnectionControl?: ReactNode;
|
||||
shellContext?: LaunchShellContext | null;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
@@ -289,7 +288,7 @@ export function Header({
|
||||
onSelectProject,
|
||||
onViewAllProjects,
|
||||
projectId,
|
||||
isElectron = false,
|
||||
shellHost = { kind: "browser" },
|
||||
mobileNavEnabled,
|
||||
availableNodes = [],
|
||||
currentNode,
|
||||
@@ -298,7 +297,6 @@ export function Header({
|
||||
experimentalFeatures,
|
||||
pluginDashboardViews = [],
|
||||
shellConnectionControl,
|
||||
shellContext,
|
||||
}: HeaderProps) {
|
||||
const mode: ViewportMode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
@@ -847,9 +845,11 @@ export function Header({
|
||||
if (onSearchChange) onSearchChange("");
|
||||
}, [onSearchChange]);
|
||||
|
||||
const isDesktopShell = shellHost.kind === "desktop-shell";
|
||||
|
||||
return (
|
||||
<div className="header-wrapper">
|
||||
<header className="header" data-shell-kind={shellContext?.shellKind ?? "web"}>
|
||||
<header className="header" data-shell-kind={shellHost.kind}>
|
||||
<div className="header-left">
|
||||
<div className="header-brand">
|
||||
<svg
|
||||
@@ -1361,7 +1361,7 @@ export function Header({
|
||||
)}
|
||||
|
||||
{/* Desktop actions */}
|
||||
{!isCompact && !isElectron && (
|
||||
{!isCompact && !isDesktopShell && (
|
||||
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
||||
<GitHubLogo size={16} />
|
||||
</button>
|
||||
@@ -1726,7 +1726,7 @@ export function Header({
|
||||
<span>Nodes</span>
|
||||
</button>
|
||||
)}
|
||||
{!isElectron && (
|
||||
{!isDesktopShell && (
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenGitHubImport)}
|
||||
|
||||
@@ -155,6 +155,34 @@ vi.mock("../../context/NodeContext", () => ({
|
||||
useNodeContext: vi.fn(() => mockNodeContextValue),
|
||||
}));
|
||||
|
||||
const mockShellHostContextValue = {
|
||||
host: { kind: "browser" as const },
|
||||
isNativeShell: false,
|
||||
kind: "browser" as const,
|
||||
};
|
||||
|
||||
vi.mock("../../context/ShellHostContext", () => ({
|
||||
ShellHostProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useShellHostContext: vi.fn(() => mockShellHostContextValue),
|
||||
}));
|
||||
|
||||
const mockShellConnectionState = {
|
||||
host: "web" as const,
|
||||
desktopMode: "local" as const,
|
||||
profiles: [],
|
||||
activeProfileId: null,
|
||||
localServer: null,
|
||||
};
|
||||
|
||||
vi.mock("../../hooks/useShellConnection", () => ({
|
||||
useShellConnection: vi.fn(() => ({
|
||||
shellApi: null,
|
||||
state: mockShellConnectionState,
|
||||
ready: true,
|
||||
openConnectionManagerSignal: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock model-onboarding-state
|
||||
const mockIsOnboardingResumable = vi.fn();
|
||||
const mockGetOnboardingResumeStep = vi.fn();
|
||||
@@ -502,6 +530,7 @@ vi.mock("../../hooks/useViewportMode", () => ({
|
||||
import { App } from "../../App";
|
||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
|
||||
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
|
||||
import { __resetShellHostContextForTests } from "../../shell-host";
|
||||
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
|
||||
|
||||
async function waitForAppShell(): Promise<void> {
|
||||
@@ -513,6 +542,7 @@ async function waitForAppShell(): Promise<void> {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetShellHostContextForTests();
|
||||
localStorage.clear();
|
||||
mockSubscribeSse.mockReset();
|
||||
mockSubscribeSse.mockReturnValue(vi.fn());
|
||||
|
||||
@@ -63,9 +63,9 @@ describe("Header", () => {
|
||||
expect(screen.getByText("Fusion")).toBeDefined();
|
||||
});
|
||||
|
||||
it("applies shell context metadata on the header root", () => {
|
||||
const { container } = renderHeader({ shellContext: { shellKind: "desktop", shellMode: "remote", capabilities: { canOpenConnectionManager: true } } });
|
||||
expect(container.querySelector("header.header")?.getAttribute("data-shell-kind")).toBe("desktop");
|
||||
it("applies shell host metadata on the header root", () => {
|
||||
const { container } = renderHeader({ shellHost: { kind: "desktop-shell", mode: "remote", canOpenConnectionManager: true } });
|
||||
expect(container.querySelector("header.header")?.getAttribute("data-shell-kind")).toBe("desktop-shell");
|
||||
});
|
||||
|
||||
it("renders action buttons", () => {
|
||||
@@ -74,6 +74,16 @@ describe("Header", () => {
|
||||
expect(screen.getByTitle("Settings")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides GitHub import for desktop shell host", () => {
|
||||
renderHeader({ shellHost: { kind: "desktop-shell" } });
|
||||
expect(screen.queryByTitle("Import from GitHub")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps GitHub import for mobile shell host", () => {
|
||||
renderHeader({ shellHost: { kind: "mobile-shell" } });
|
||||
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders system stats button on desktop when handler is provided", () => {
|
||||
renderHeader({ onOpenSystemStats: vi.fn() }, "desktop");
|
||||
expect(screen.getByTitle("System Stats")).toBeDefined();
|
||||
|
||||
44
packages/dashboard/app/context/ShellHostContext.tsx
Normal file
44
packages/dashboard/app/context/ShellHostContext.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createContext, useContext, useMemo, type PropsWithChildren } from "react";
|
||||
import { getShellHostContext, type ShellHostContext } from "../shell-host";
|
||||
|
||||
export interface ShellHostContextValue {
|
||||
host: ShellHostContext;
|
||||
isNativeShell: boolean;
|
||||
kind: ShellHostContext["kind"];
|
||||
mode?: "local" | "remote";
|
||||
connectionId?: string;
|
||||
serverUrl?: string;
|
||||
canOpenConnectionManager?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST: ShellHostContext = { kind: "browser" };
|
||||
|
||||
const ShellHostContextReact = createContext<ShellHostContextValue>({
|
||||
host: DEFAULT_HOST,
|
||||
isNativeShell: false,
|
||||
kind: "browser",
|
||||
});
|
||||
|
||||
function buildValue(host: ShellHostContext): ShellHostContextValue {
|
||||
const isNativeShell = host.kind !== "browser";
|
||||
return {
|
||||
host,
|
||||
isNativeShell,
|
||||
kind: host.kind,
|
||||
...(isNativeShell ? {
|
||||
mode: host.mode,
|
||||
connectionId: host.connectionId,
|
||||
serverUrl: host.serverUrl,
|
||||
canOpenConnectionManager: host.canOpenConnectionManager,
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function ShellHostProvider({ children }: PropsWithChildren) {
|
||||
const value = useMemo(() => buildValue(getShellHostContext()), []);
|
||||
return <ShellHostContextReact.Provider value={value}>{children}</ShellHostContextReact.Provider>;
|
||||
}
|
||||
|
||||
export function useShellHostContext(): ShellHostContextValue {
|
||||
return useContext(ShellHostContextReact);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { ShellHostProvider, useShellHostContext } from "../ShellHostContext";
|
||||
import { __resetShellHostContextForTests } from "../../shell-host";
|
||||
|
||||
function Probe() {
|
||||
const value = useShellHostContext();
|
||||
return <pre data-testid="host">{JSON.stringify(value)}</pre>;
|
||||
}
|
||||
|
||||
describe("ShellHostContext", () => {
|
||||
beforeEach(() => {
|
||||
__resetShellHostContextForTests();
|
||||
window.history.replaceState({}, "", "/");
|
||||
delete (window as Window & Record<string, unknown>).__FUSION_SHELL_HOST_CONTEXT__;
|
||||
});
|
||||
|
||||
it("provides browser defaults", () => {
|
||||
render(
|
||||
<ShellHostProvider>
|
||||
<Probe />
|
||||
</ShellHostProvider>,
|
||||
);
|
||||
|
||||
const value = JSON.parse(screen.getByTestId("host").textContent ?? "{}");
|
||||
expect(value.kind).toBe("browser");
|
||||
expect(value.isNativeShell).toBe(false);
|
||||
});
|
||||
|
||||
it("provides normalized shell fields", () => {
|
||||
(window as Window & Record<string, unknown>).__FUSION_SHELL_HOST_CONTEXT__ = {
|
||||
kind: "desktop-shell",
|
||||
mode: "remote",
|
||||
connectionId: "conn-2",
|
||||
serverUrl: "https://remote.example.com",
|
||||
canOpenConnectionManager: true,
|
||||
};
|
||||
|
||||
render(
|
||||
<ShellHostProvider>
|
||||
<Probe />
|
||||
</ShellHostProvider>,
|
||||
);
|
||||
|
||||
const value = JSON.parse(screen.getByTestId("host").textContent ?? "{}");
|
||||
expect(value.isNativeShell).toBe(true);
|
||||
expect(value.kind).toBe("desktop-shell");
|
||||
expect(value.mode).toBe("remote");
|
||||
expect(value.connectionId).toBe("conn-2");
|
||||
expect(value.serverUrl).toBe("https://remote.example.com");
|
||||
expect(value.canOpenConnectionManager).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { App } from "./App";
|
||||
import { installAuthFetch } from "./auth";
|
||||
import { installVersionCheck } from "./versionCheck";
|
||||
import { installSwUpdate } from "./swUpdate";
|
||||
import { bootstrapShellHostContext } from "./shell-host";
|
||||
import "./styles.css";
|
||||
|
||||
// Install the bearer-token fetch wrapper before React mounts so every API
|
||||
@@ -13,6 +14,7 @@ import "./styles.css";
|
||||
// stored from a previous session.
|
||||
installAuthFetch();
|
||||
installVersionCheck();
|
||||
bootstrapShellHostContext();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
179
packages/dashboard/app/shell-host.ts
Normal file
179
packages/dashboard/app/shell-host.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
export type ShellHostKind = "browser" | "desktop-shell" | "mobile-shell";
|
||||
export type ShellHostMode = "local" | "remote";
|
||||
|
||||
export type ShellHostContext =
|
||||
| { kind: "browser" }
|
||||
| {
|
||||
kind: "desktop-shell" | "mobile-shell";
|
||||
mode?: ShellHostMode;
|
||||
connectionId?: string;
|
||||
serverUrl?: string;
|
||||
canOpenConnectionManager?: boolean;
|
||||
};
|
||||
|
||||
export const SHELL_HOST_QUERY_KEYS = [
|
||||
"shellKind",
|
||||
"shellMode",
|
||||
"profileId",
|
||||
"serverBaseUrl",
|
||||
"serverLabel",
|
||||
"shellCanOpenConnectionManager",
|
||||
"hostKind",
|
||||
"mode",
|
||||
"connectionId",
|
||||
"serverUrl",
|
||||
"canOpenConnectionManager",
|
||||
] as const;
|
||||
|
||||
const BOOTSTRAP_GLOBAL_KEYS = [
|
||||
"__FUSION_SHELL_HOST_CONTEXT__",
|
||||
"__fusionShellHostContext",
|
||||
"__FUSION_SHELL_CONTEXT__",
|
||||
] as const;
|
||||
|
||||
let cachedContext: ShellHostContext | null = null;
|
||||
let bootstrapped = false;
|
||||
|
||||
function parseBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (value === "1" || value === "true") return true;
|
||||
if (value === "0" || value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeServerUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || value.trim().length === 0) return undefined;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKind(value: unknown): ShellHostKind | undefined {
|
||||
if (value === "browser" || value === "desktop-shell" || value === "mobile-shell") return value;
|
||||
if (value === "desktop") return "desktop-shell";
|
||||
if (value === "mobile") return "mobile-shell";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeMode(value: unknown): ShellHostMode | undefined {
|
||||
return value === "local" || value === "remote" ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeNativeHost(input: {
|
||||
kind: "desktop-shell" | "mobile-shell";
|
||||
mode?: unknown;
|
||||
connectionId?: unknown;
|
||||
serverUrl?: unknown;
|
||||
canOpenConnectionManager?: unknown;
|
||||
}): ShellHostContext {
|
||||
const mode = normalizeMode(input.mode);
|
||||
const connectionId = typeof input.connectionId === "string" && input.connectionId.trim() ? input.connectionId : undefined;
|
||||
const serverUrl = normalizeServerUrl(input.serverUrl);
|
||||
const canOpenConnectionManager = parseBoolean(input.canOpenConnectionManager);
|
||||
|
||||
return {
|
||||
kind: input.kind,
|
||||
...(mode ? { mode } : {}),
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
...(serverUrl ? { serverUrl } : {}),
|
||||
...(canOpenConnectionManager !== undefined ? { canOpenConnectionManager } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function fromBootstrapGlobal(target: Window): ShellHostContext | null {
|
||||
for (const key of BOOTSTRAP_GLOBAL_KEYS) {
|
||||
const raw = (target as Window & Record<string, unknown>)[key];
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const kind = normalizeKind(record.kind ?? record.shellKind ?? record.hostKind);
|
||||
if (!kind) continue;
|
||||
if (kind === "browser") return { kind };
|
||||
return normalizeNativeHost({
|
||||
kind,
|
||||
mode: record.mode ?? record.shellMode,
|
||||
connectionId: record.connectionId ?? record.profileId,
|
||||
serverUrl: record.serverUrl ?? record.serverBaseUrl,
|
||||
canOpenConnectionManager: record.canOpenConnectionManager ?? record.shellCanOpenConnectionManager,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fromQuery(target: Window): ShellHostContext | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(target.location.href);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const params = url.searchParams;
|
||||
const kind = normalizeKind(params.get("hostKind") ?? params.get("kind") ?? params.get("shellKind"));
|
||||
if (!kind) return null;
|
||||
if (kind === "browser") return { kind };
|
||||
return normalizeNativeHost({
|
||||
kind,
|
||||
mode: params.get("mode") ?? params.get("shellMode"),
|
||||
connectionId: params.get("connectionId") ?? params.get("profileId"),
|
||||
serverUrl: params.get("serverUrl") ?? params.get("serverBaseUrl"),
|
||||
canOpenConnectionManager: params.get("canOpenConnectionManager") ?? params.get("shellCanOpenConnectionManager"),
|
||||
});
|
||||
}
|
||||
|
||||
function stripShellQueryParams(target: Window): void {
|
||||
try {
|
||||
const url = new URL(target.location.href);
|
||||
let changed = false;
|
||||
for (const key of SHELL_HOST_QUERY_KEYS) {
|
||||
if (url.searchParams.has(key)) {
|
||||
url.searchParams.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
const cleaned = url.pathname + (url.search ? url.search : "") + url.hash;
|
||||
target.history.replaceState(target.history.state, "", cleaned);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
export function detectShellHostContext(target: Window = window): ShellHostContext {
|
||||
const globalContext = fromBootstrapGlobal(target);
|
||||
if (globalContext) return globalContext;
|
||||
|
||||
const queryContext = fromQuery(target);
|
||||
if (queryContext) return queryContext;
|
||||
|
||||
if (typeof (target as Window & { fusionAPI?: unknown }).fusionAPI !== "undefined") {
|
||||
return { kind: "desktop-shell" };
|
||||
}
|
||||
|
||||
return { kind: "browser" };
|
||||
}
|
||||
|
||||
export function bootstrapShellHostContext(target: Window = window): ShellHostContext {
|
||||
if (!bootstrapped && typeof window !== "undefined") {
|
||||
cachedContext = detectShellHostContext(target);
|
||||
stripShellQueryParams(target);
|
||||
bootstrapped = true;
|
||||
}
|
||||
return cachedContext ?? { kind: "browser" };
|
||||
}
|
||||
|
||||
export function getShellHostContext(): ShellHostContext {
|
||||
if (cachedContext) return cachedContext;
|
||||
if (typeof window === "undefined") return { kind: "browser" };
|
||||
return bootstrapShellHostContext(window);
|
||||
}
|
||||
|
||||
export function __resetShellHostContextForTests(): void {
|
||||
cachedContext = null;
|
||||
bootstrapped = false;
|
||||
}
|
||||
Reference in New Issue
Block a user