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:
@@ -128,6 +128,7 @@ describe("ipc handlers", () => {
|
||||
expect(channels.has("desktopRuntime:startLocal")).toBe(true);
|
||||
expect(channels.has("desktopRuntime:stopLocal")).toBe(true);
|
||||
expect(channels.has("desktopLaunchMode:getMode")).toBe(true);
|
||||
expect(channels.has("desktopLaunchMode:getContext")).toBe(true);
|
||||
expect(channels.has("desktopLaunchMode:setMode")).toBe(true);
|
||||
expect(channels.has("platform:get")).toBe(true);
|
||||
});
|
||||
@@ -155,12 +156,14 @@ describe("ipc handlers", () => {
|
||||
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
|
||||
});
|
||||
|
||||
it("desktop launch mode handlers return mode and validate payload", async () => {
|
||||
it("desktop launch mode handlers return mode/context and validate payload", async () => {
|
||||
const getDesktopLaunchContext = vi.fn(() => ({ mode: "remote", profileId: "profile_1", serverBaseUrl: "https://remote.example.com" }));
|
||||
const onDesktopLaunchModeChange = vi.fn(async () => undefined);
|
||||
const getDesktopLaunchMode = vi.fn(() => "remote");
|
||||
await registerHandlers({ onDesktopLaunchModeChange, getDesktopLaunchMode });
|
||||
await registerHandlers({ onDesktopLaunchModeChange, getDesktopLaunchMode, getDesktopLaunchContext });
|
||||
|
||||
await expect(mocks.ipcHandlers.get("desktopLaunchMode:getMode")?.({})).resolves.toBe("remote");
|
||||
await expect(mocks.ipcHandlers.get("desktopLaunchMode:getContext")?.({})).resolves.toEqual({ mode: "remote", profileId: "profile_1", serverBaseUrl: "https://remote.example.com" });
|
||||
await expect(mocks.ipcHandlers.get("desktopLaunchMode:setMode")?.({}, "local")).resolves.toBe("remote");
|
||||
await expect(mocks.ipcHandlers.get("desktopLaunchMode:setMode")?.({}, "bad")).rejects.toThrow("Invalid desktop launch mode");
|
||||
expect(onDesktopLaunchModeChange).toHaveBeenCalledWith("local");
|
||||
|
||||
@@ -168,6 +168,11 @@ vi.mock("../native.js", () => ({
|
||||
saveWindowState: mocks.saveWindowState,
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
DEFAULT_WINDOW_STATE: mocks.DEFAULT_WINDOW_STATE,
|
||||
normalizeDesktopRemoteLaunch: vi.fn((settings) => {
|
||||
const active = settings.profiles.find((profile: { id: string }) => profile.id === settings.activeProfileId);
|
||||
return active ? { mode: "remote", profileId: active.id, serverBaseUrl: active.serverUrl.replace(/\/$/, ""), serverLabel: active.name, authToken: active.authToken ?? undefined } : null;
|
||||
}),
|
||||
buildRemoteShellHandoffUrl: vi.fn((launch) => `https://remote.example.com?shellMode=remote&profileId=${launch.profileId}`),
|
||||
}));
|
||||
|
||||
vi.mock("../local-runtime.js", () => ({
|
||||
@@ -180,6 +185,14 @@ vi.mock("../local-runtime.js", () => ({
|
||||
}));
|
||||
|
||||
// Mock renderer module
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
readShellSettings: vi.fn(async () => ({
|
||||
desktopMode: "remote",
|
||||
activeProfileId: "profile_1",
|
||||
profiles: [{ id: "profile_1", name: "Remote", serverUrl: "https://remote.example.com", authToken: "token" }],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../renderer.js", () => ({
|
||||
isDevelopmentMode: vi.fn(() => false),
|
||||
getRendererUrl: vi.fn(() => "file:///path/to/dist/client/index.html"),
|
||||
@@ -375,6 +388,17 @@ describe("main integration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("loads remote handoff URL when remembered mode is remote", async () => {
|
||||
mocks.loadDesktopLaunchMode.mockResolvedValueOnce("remote");
|
||||
const { initializeApp } = await importMainModule();
|
||||
|
||||
await initializeApp();
|
||||
|
||||
const [{ instance }] = mocks.windowInstances;
|
||||
expect(instance.loadURL).toHaveBeenCalledWith(expect.stringContaining("shellMode=remote"));
|
||||
expect(mocks.startLocal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts local runtime when remembered mode is local", async () => {
|
||||
mocks.loadDesktopLaunchMode.mockResolvedValueOnce("local");
|
||||
const { initializeApp } = await importMainModule();
|
||||
|
||||
@@ -137,11 +137,24 @@ vi.mock("../native.js", () => ({
|
||||
saveDesktopLaunchMode: mainDeps.saveDesktopLaunchMode,
|
||||
saveWindowState: mainDeps.saveWindowState,
|
||||
setupAutoUpdater: mainDeps.setupAutoUpdater,
|
||||
normalizeDesktopRemoteLaunch: vi.fn((settings) => {
|
||||
const active = settings.profiles.find((profile: { id: string }) => profile.id === settings.activeProfileId);
|
||||
return active ? { mode: "remote", profileId: active.id, serverBaseUrl: active.serverUrl.replace(/\/$/, ""), serverLabel: active.name, authToken: active.authToken ?? undefined } : null;
|
||||
}),
|
||||
buildRemoteShellHandoffUrl: vi.fn((launch) => `https://remote.example.com?shellKind=desktop&shellMode=remote&profileId=${launch.profileId}`),
|
||||
}));
|
||||
vi.mock("../local-runtime.js", () => ({
|
||||
LocalRuntimeManager: mainDeps.LocalRuntimeManager,
|
||||
}));
|
||||
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
readShellSettings: vi.fn(async () => ({
|
||||
desktopMode: "remote",
|
||||
activeProfileId: "profile_1",
|
||||
profiles: [{ id: "profile_1", name: "Remote", serverUrl: "https://remote.example.com", authToken: "token" }],
|
||||
})),
|
||||
}));
|
||||
|
||||
async function importMainModule() {
|
||||
return import("../main.ts");
|
||||
}
|
||||
@@ -264,13 +277,16 @@ describe("main process", () => {
|
||||
expect(mainDeps.startLocal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("initializeApp does not start local runtime for remembered remote mode", async () => {
|
||||
it("initializeApp routes remembered remote mode to remote dashboard handoff URL", async () => {
|
||||
mainDeps.loadDesktopLaunchMode.mockResolvedValueOnce("remote");
|
||||
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
|
||||
|
||||
await initializeApp();
|
||||
|
||||
expect(mainDeps.startLocal).not.toHaveBeenCalled();
|
||||
expect(mocks.browserWindowInstance.loadURL).toHaveBeenCalledWith(
|
||||
expect.stringContaining("shellMode=remote"),
|
||||
);
|
||||
expect(getCurrentDesktopLaunchMode()).toBe("remote");
|
||||
});
|
||||
|
||||
|
||||
@@ -54,13 +54,16 @@ describe("preload", () => {
|
||||
await importPreloadModule();
|
||||
const api = getExposed<{
|
||||
getDesktopLaunchMode: () => Promise<string>;
|
||||
getDesktopLaunchContext: () => Promise<unknown>;
|
||||
setDesktopLaunchMode: (mode: "choose" | "local" | "remote") => Promise<string>;
|
||||
}>("electronAPI");
|
||||
|
||||
await api?.getDesktopLaunchMode();
|
||||
await api?.getDesktopLaunchContext();
|
||||
await api?.setDesktopLaunchMode("local");
|
||||
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:getMode");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:getContext");
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("desktopLaunchMode:setMode", "local");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
|
||||
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog } from "./native.js";
|
||||
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog, type NormalizedDesktopRemoteLaunch } from "./native.js";
|
||||
import { type EngineStatus, updateTrayStatus } from "./tray.js";
|
||||
import {
|
||||
applyDeleteProfile,
|
||||
@@ -42,6 +42,7 @@ interface RegisterIpcOptions {
|
||||
stopLocalRuntime?: () => Promise<DesktopRuntimeStatus>;
|
||||
getServerPort?: () => number | undefined;
|
||||
getDesktopLaunchMode?: () => DesktopLaunchMode;
|
||||
getDesktopLaunchContext?: () => NormalizedDesktopRemoteLaunch | null;
|
||||
}
|
||||
|
||||
function isDesktopLaunchMode(value: unknown): value is DesktopLaunchMode {
|
||||
@@ -112,6 +113,7 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
||||
ipcMain.handle("desktopRuntime:startLocal", async () => options.startLocalRuntime?.() ?? { source: "none", state: "stopped" });
|
||||
ipcMain.handle("desktopRuntime:stopLocal", async () => options.stopLocalRuntime?.() ?? { source: "none", state: "stopped" });
|
||||
ipcMain.handle("desktopLaunchMode:getMode", async () => options.getDesktopLaunchMode?.() ?? "choose");
|
||||
ipcMain.handle("desktopLaunchMode:getContext", async () => options.getDesktopLaunchContext?.() ?? null);
|
||||
ipcMain.handle("desktopLaunchMode:setMode", async (_event, mode: unknown) => {
|
||||
if (!isDesktopLaunchMode(mode)) {
|
||||
throw new Error("Invalid desktop launch mode");
|
||||
|
||||
@@ -11,12 +11,16 @@ import {
|
||||
saveDesktopLaunchMode,
|
||||
saveWindowState,
|
||||
setupAutoUpdater,
|
||||
normalizeDesktopRemoteLaunch,
|
||||
buildRemoteShellHandoffUrl,
|
||||
type DesktopLaunchMode,
|
||||
type NormalizedDesktopRemoteLaunch,
|
||||
type WindowState,
|
||||
} from "./native.js";
|
||||
import { setupTray } from "./tray.js";
|
||||
import { getRendererUrl, getRendererFilePath, isUrlRenderer } from "./renderer.js";
|
||||
import { LocalRuntimeManager } from "./local-runtime.js";
|
||||
import { readShellSettings } from "./shell-settings.js";
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { IS_DEVELOPMENT } from "./renderer.js";
|
||||
@@ -39,6 +43,7 @@ let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let localRuntimeManager: LocalRuntimeManager | null = null;
|
||||
let currentDesktopLaunchMode: DesktopLaunchMode = "choose";
|
||||
let currentRemoteLaunch: NormalizedDesktopRemoteLaunch | null = null;
|
||||
let localRuntimeStartupAttempted = false;
|
||||
|
||||
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
|
||||
@@ -64,7 +69,7 @@ export function getCurrentDesktopLaunchMode(): DesktopLaunchMode {
|
||||
return currentDesktopLaunchMode;
|
||||
}
|
||||
|
||||
export function createMainWindow(state?: WindowState): BrowserWindow {
|
||||
export function createMainWindow(state?: WindowState, launchTargetUrl?: string): BrowserWindow {
|
||||
const hasValidPosition = typeof state?.x === "number" && typeof state?.y === "number";
|
||||
|
||||
const window = new BrowserWindow({
|
||||
@@ -79,7 +84,9 @@ export function createMainWindow(state?: WindowState): BrowserWindow {
|
||||
},
|
||||
});
|
||||
|
||||
if (isUrlRenderer()) {
|
||||
if (launchTargetUrl) {
|
||||
void window.loadURL(launchTargetUrl);
|
||||
} else if (isUrlRenderer()) {
|
||||
void window.loadURL(getRendererUrl());
|
||||
} else {
|
||||
void window.loadFile(getRendererFilePath());
|
||||
@@ -110,8 +117,20 @@ export async function initializeApp(): Promise<void> {
|
||||
|
||||
localRuntimeManager = new LocalRuntimeManager({ rootDir: process.cwd() });
|
||||
currentDesktopLaunchMode = rememberedLaunchMode;
|
||||
currentRemoteLaunch = null;
|
||||
localRuntimeStartupAttempted = false;
|
||||
|
||||
if (rememberedLaunchMode === "remote") {
|
||||
const shellSettings = await readShellSettings();
|
||||
const normalizedRemoteLaunch = normalizeDesktopRemoteLaunch(shellSettings);
|
||||
if (normalizedRemoteLaunch) {
|
||||
currentRemoteLaunch = normalizedRemoteLaunch;
|
||||
} else {
|
||||
currentDesktopLaunchMode = "choose";
|
||||
await saveDesktopLaunchMode("choose");
|
||||
}
|
||||
}
|
||||
|
||||
if (rememberedLaunchMode === "local") {
|
||||
try {
|
||||
await startLocalRuntimeOnce();
|
||||
@@ -129,7 +148,12 @@ export async function initializeApp(): Promise<void> {
|
||||
currentDesktopLaunchMode = "local";
|
||||
}
|
||||
|
||||
const createdWindow = createMainWindow(state ?? undefined);
|
||||
const createdWindow = createMainWindow(
|
||||
state ?? undefined,
|
||||
currentDesktopLaunchMode === "remote" && currentRemoteLaunch
|
||||
? buildRemoteShellHandoffUrl(currentRemoteLaunch)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
buildAppMenu({
|
||||
mainWindow: createdWindow,
|
||||
@@ -146,11 +170,14 @@ export async function initializeApp(): Promise<void> {
|
||||
}
|
||||
currentDesktopLaunchMode = mode;
|
||||
if (mode === "local") {
|
||||
currentRemoteLaunch = null;
|
||||
localRuntimeStartupAttempted = false;
|
||||
await startLocalRuntimeOnce();
|
||||
} else {
|
||||
localRuntimeStartupAttempted = false;
|
||||
await localRuntimeManager.stopLocal();
|
||||
const shellSettings = await readShellSettings();
|
||||
currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" });
|
||||
}
|
||||
await saveDesktopLaunchMode(mode);
|
||||
},
|
||||
@@ -161,9 +188,12 @@ export async function initializeApp(): Promise<void> {
|
||||
currentDesktopLaunchMode = mode;
|
||||
localRuntimeStartupAttempted = false;
|
||||
if (mode === "local") {
|
||||
currentRemoteLaunch = null;
|
||||
await startLocalRuntimeOnce();
|
||||
} else {
|
||||
await localRuntimeManager.stopLocal();
|
||||
const shellSettings = await readShellSettings();
|
||||
currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" });
|
||||
}
|
||||
await saveDesktopLaunchMode(mode);
|
||||
},
|
||||
@@ -172,6 +202,7 @@ export async function initializeApp(): Promise<void> {
|
||||
stopLocalRuntime: () => localRuntimeManager?.stopLocal() ?? Promise.resolve({ source: "none", state: "stopped" }),
|
||||
getServerPort: () => localRuntimeManager?.getServerPort(),
|
||||
getDesktopLaunchMode: () => currentDesktopLaunchMode,
|
||||
getDesktopLaunchContext: () => currentRemoteLaunch,
|
||||
});
|
||||
registerDeepLinkProtocol();
|
||||
setupDeepLinkHandler(createdWindow);
|
||||
|
||||
@@ -20,6 +20,37 @@ export interface WindowState {
|
||||
|
||||
export type DesktopLaunchMode = "choose" | "local" | "remote";
|
||||
|
||||
export interface DesktopRemoteProfileLike {
|
||||
id: string;
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
authToken?: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopShellSettingsLike {
|
||||
desktopMode: "local" | "remote" | null;
|
||||
activeProfileId: string | null;
|
||||
profiles: DesktopRemoteProfileLike[];
|
||||
}
|
||||
|
||||
export interface NormalizedDesktopRemoteLaunch {
|
||||
mode: "remote";
|
||||
profileId: string;
|
||||
serverBaseUrl: string;
|
||||
serverLabel?: string;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export const SHELL_HANDOFF_QUERY = {
|
||||
shellKind: "shellKind",
|
||||
shellMode: "shellMode",
|
||||
profileId: "profileId",
|
||||
serverBaseUrl: "serverBaseUrl",
|
||||
serverLabel: "serverLabel",
|
||||
token: "token",
|
||||
canOpenConnectionManager: "shellCanOpenConnectionManager",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_WINDOW_STATE: WindowState = {
|
||||
width: 1280,
|
||||
height: 900,
|
||||
@@ -166,6 +197,58 @@ export function setupAutoUpdater(mainWindow?: BrowserWindow): void {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServerBaseUrl(serverUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(serverUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDesktopRemoteLaunch(settings: DesktopShellSettingsLike): NormalizedDesktopRemoteLaunch | null {
|
||||
if (settings.desktopMode !== "remote" || !settings.activeProfileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeProfile = settings.profiles.find((profile) => profile.id === settings.activeProfileId);
|
||||
if (!activeProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverBaseUrl = normalizeServerBaseUrl(activeProfile.serverUrl);
|
||||
if (!serverBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "remote",
|
||||
profileId: activeProfile.id,
|
||||
serverBaseUrl,
|
||||
...(activeProfile.name ? { serverLabel: activeProfile.name } : {}),
|
||||
...(activeProfile.authToken ? { authToken: activeProfile.authToken } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRemoteShellHandoffUrl(launch: NormalizedDesktopRemoteLaunch): string {
|
||||
const url = new URL(launch.serverBaseUrl);
|
||||
url.searchParams.set(SHELL_HANDOFF_QUERY.shellKind, "desktop");
|
||||
url.searchParams.set(SHELL_HANDOFF_QUERY.shellMode, "remote");
|
||||
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.authToken) {
|
||||
url.searchParams.set(SHELL_HANDOFF_QUERY.token, launch.authToken);
|
||||
}
|
||||
url.searchParams.set(SHELL_HANDOFF_QUERY.canOpenConnectionManager, "1");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function isValidDesktopLaunchMode(value: unknown): value is DesktopLaunchMode {
|
||||
return value === "choose" || value === "local" || value === "remote";
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ const electronApi = {
|
||||
getDesktopLaunchMode: (): Promise<"choose" | "local" | "remote"> => ipcRenderer.invoke("desktopLaunchMode:getMode"),
|
||||
setDesktopLaunchMode: (mode: "choose" | "local" | "remote"): Promise<"choose" | "local" | "remote"> =>
|
||||
ipcRenderer.invoke("desktopLaunchMode:setMode", mode),
|
||||
getDesktopLaunchContext: (): Promise<{ mode: "remote"; profileId: string; serverBaseUrl: string; serverLabel?: string; authToken?: string } | null> =>
|
||||
ipcRenderer.invoke("desktopLaunchMode:getContext"),
|
||||
|
||||
// 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
@@ -36,6 +36,7 @@ export interface FusionAPI {
|
||||
stopDesktopLocalRuntime(): Promise<ShellConnectionState["localRuntime"]>;
|
||||
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>;
|
||||
|
||||
// Tray status
|
||||
updateTrayStatus(status: string): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user