feat(FN-3399): route desktop remote mode into shell onboarding flow
Removes the standalone `DesktopShellBootstrap` component from the desktop package, routing desktop remote mode into the shell onboarding flow instead. Updates the corresponding test file to reflect the component removal and adjusts the README. Fusion-Task-Id: FN-3399
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import "./MobileNavBar.css";
|
import "./MobileNavBar.css";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bot,
|
Bot,
|
||||||
@@ -85,6 +85,7 @@ export interface MobileNavBarProps {
|
|||||||
};
|
};
|
||||||
onOpenNodes?: () => void;
|
onOpenNodes?: () => void;
|
||||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||||
|
shellConnectionControl?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GitHubLogo({ size = 20 }: { size?: number }) {
|
function GitHubLogo({ size = 20 }: { size?: number }) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useState, type PropsWithChildren } from "react";
|
import { createContext, useContext, useEffect, useMemo, useState, type PropsWithChildren } from "react";
|
||||||
import type { FusionShellApi, ShellConnectionState } from "../types/native-shell";
|
import type { FusionShellApi, ShellConnectionState } from "../types/native-shell";
|
||||||
|
|
||||||
interface ShellContextValue {
|
export interface ShellContextValue {
|
||||||
shellApi: FusionShellApi | null;
|
shellApi: FusionShellApi | null;
|
||||||
state: ShellConnectionState;
|
state: ShellConnectionState;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
|||||||
@@ -61,13 +61,19 @@ getRendererFilePath() // Returns absolute file path for loadFile()
|
|||||||
|
|
||||||
## First-run Shell Onboarding (Desktop)
|
## First-run Shell Onboarding (Desktop)
|
||||||
|
|
||||||
Desktop now boots through a shell-level onboarding gate before dashboard onboarding when no usable shell connection state exists.
|
Desktop boots through a shell-owned mode chooser before mounting the dashboard app when the user has not completed mode selection yet.
|
||||||
|
|
||||||
- **First run choice:** users choose **Local Fusion (bundled runtime)** or **Remote Server**.
|
- **First run choice:** users choose **Local Fusion (bundled runtime)** or **Remote connection path**.
|
||||||
- **Desktop mode restore:** last-used mode is persisted and restored on relaunch.
|
- **Mode contract:** `desktopMode` is `"local" | "remote" | null` and `hasCompletedModeSelection` determines whether the renderer treats startup as first-run. IPC also exposes a renderer-safe `{ isFirstRun, desktopMode }` shape via `shell:getDesktopModeState`.
|
||||||
|
- **Desktop mode restore:** after selection, mode is persisted and reused on relaunch.
|
||||||
- **Remote profiles:** multiple saved profiles are supported (`name`, `serverUrl`, optional `authToken`) and can be managed/switched later from the dashboard header connection UI.
|
- **Remote profiles:** multiple saved profiles are supported (`name`, `serverUrl`, optional `authToken`) and can be managed/switched later from the dashboard header connection UI.
|
||||||
- **Storage boundary:** shell connection state is stored only in desktop-local app data at `app.getPath("userData")/shell-connections.json` and is not written to `.fusion/config.json` or dashboard project storage keys.
|
- **Storage boundary:** shell connection state is stored only in desktop-local app data at `app.getPath("userData")/shell-connections.json` and is not written to `.fusion/config.json` or dashboard project storage keys.
|
||||||
|
|
||||||
|
### Production vs dev bootstrap behavior
|
||||||
|
|
||||||
|
- **Production (`fn desktop`)**: renderer mounts `DesktopShellBootstrap`, which resolves shell mode via preload/IPC and either renders the chooser or mounts the dashboard shell. In remote mode, the dashboard shell opens the native connection onboarding/manager flow instead of the local runtime path.
|
||||||
|
- **Dev (`pnpm --filter @fusion/desktop dev`)**: same mode bootstrap flow runs; only the renderer source (Vite URL vs bundled file) changes.
|
||||||
|
|
||||||
## IPC Channel Reference
|
## IPC Channel Reference
|
||||||
|
|
||||||
`src/ipc.ts` registers renderer ↔ main process bridges used by `window.electronAPI` (desktop renderer transport/window controls) and `window.fusionShell` (shared shell connection contract for dashboard code).
|
`src/ipc.ts` registers renderer ↔ main process bridges used by `window.electronAPI` (desktop renderer transport/window controls) and `window.fusionShell` (shared shell connection contract for dashboard code).
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ const mocks = vi.hoisted(() => {
|
|||||||
const showExportSettingsDialog = vi.fn();
|
const showExportSettingsDialog = vi.fn();
|
||||||
const showImportSettingsDialog = vi.fn();
|
const showImportSettingsDialog = vi.fn();
|
||||||
const setupAutoUpdater = vi.fn();
|
const setupAutoUpdater = vi.fn();
|
||||||
const readShellSettings = vi.fn(async () => ({ desktopMode: "remote", activeProfileId: null, profiles: [] }));
|
const readShellSettings = vi.fn(async () => ({
|
||||||
|
desktopMode: "remote",
|
||||||
|
hasCompletedModeSelection: true,
|
||||||
|
activeProfileId: null,
|
||||||
|
profiles: [],
|
||||||
|
}));
|
||||||
const writeShellSettings = vi.fn(async () => undefined);
|
const writeShellSettings = vi.fn(async () => undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -51,6 +56,10 @@ vi.mock("../native.js", () => ({
|
|||||||
vi.mock("../shell-settings.js", () => ({
|
vi.mock("../shell-settings.js", () => ({
|
||||||
readShellSettings: mocks.readShellSettings,
|
readShellSettings: mocks.readShellSettings,
|
||||||
writeShellSettings: mocks.writeShellSettings,
|
writeShellSettings: mocks.writeShellSettings,
|
||||||
|
getDesktopShellModeState: (settings: { hasCompletedModeSelection?: boolean; desktopMode?: "local" | "remote" | null }) => ({
|
||||||
|
isFirstRun: !settings.hasCompletedModeSelection || !settings.desktopMode,
|
||||||
|
desktopMode: settings.desktopMode ?? null,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function createWindowMock() {
|
function createWindowMock() {
|
||||||
@@ -92,6 +101,7 @@ describe("ipc handlers", () => {
|
|||||||
const channels = new Set(mocks.ipcMain.handle.mock.calls.map(([channel]) => channel));
|
const channels = new Set(mocks.ipcMain.handle.mock.calls.map(([channel]) => channel));
|
||||||
expect(channels.has("shell:getState")).toBe(true);
|
expect(channels.has("shell:getState")).toBe(true);
|
||||||
expect(channels.has("shell:saveProfile")).toBe(true);
|
expect(channels.has("shell:saveProfile")).toBe(true);
|
||||||
|
expect(channels.has("shell:getDesktopModeState")).toBe(true);
|
||||||
expect(channels.has("shell:setDesktopMode")).toBe(true);
|
expect(channels.has("shell:setDesktopMode")).toBe(true);
|
||||||
expect(channels.has("platform:get")).toBe(true);
|
expect(channels.has("platform:get")).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -102,6 +112,7 @@ describe("ipc handlers", () => {
|
|||||||
const result = await handler?.({});
|
const result = await handler?.({});
|
||||||
|
|
||||||
expect(result).toMatchObject({ host: "desktop-shell", desktopMode: "remote" });
|
expect(result).toMatchObject({ host: "desktop-shell", desktopMode: "remote" });
|
||||||
|
expect(result).toMatchObject({ desktopModeState: { isFirstRun: false, desktopMode: "remote" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shell:setDesktopMode persists mode and emits state", async () => {
|
it("shell:setDesktopMode persists mode and emits state", async () => {
|
||||||
@@ -110,7 +121,9 @@ describe("ipc handlers", () => {
|
|||||||
const handler = mocks.ipcHandlers.get("shell:setDesktopMode");
|
const handler = mocks.ipcHandlers.get("shell:setDesktopMode");
|
||||||
await handler?.({}, "local");
|
await handler?.({}, "local");
|
||||||
|
|
||||||
expect(mocks.writeShellSettings).toHaveBeenCalled();
|
expect(mocks.writeShellSettings).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ desktopMode: "local", hasCompletedModeSelection: true }),
|
||||||
|
);
|
||||||
expect(onDesktopModeChange).toHaveBeenCalledWith("local");
|
expect(onDesktopModeChange).toHaveBeenCalledWith("local");
|
||||||
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
|
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,15 @@ vi.mock("../tray.js", () => ({ setupTray: vi.fn() }));
|
|||||||
vi.mock("../ipc.js", () => ({ registerIpcHandlers: vi.fn() }));
|
vi.mock("../ipc.js", () => ({ registerIpcHandlers: vi.fn() }));
|
||||||
vi.mock("../native.js", () => ({ DEFAULT_WINDOW_STATE: { width: 1000, height: 800 }, loadWindowState: vi.fn(async () => null), saveWindowState: vi.fn(), setupAutoUpdater: vi.fn() }));
|
vi.mock("../native.js", () => ({ DEFAULT_WINDOW_STATE: { width: 1000, height: 800 }, loadWindowState: vi.fn(async () => null), saveWindowState: vi.fn(), setupAutoUpdater: vi.fn() }));
|
||||||
vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() }));
|
vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() }));
|
||||||
vi.mock("../shell-settings.js", () => ({ readShellSettings: vi.fn(async () => ({ desktopMode: "local", activeProfileId: null, profiles: [] })) }));
|
vi.mock("../shell-settings.js", () => ({
|
||||||
|
readShellSettings: vi.fn(async () => ({
|
||||||
|
desktopMode: "local",
|
||||||
|
hasCompletedModeSelection: true,
|
||||||
|
activeProfileId: null,
|
||||||
|
profiles: [],
|
||||||
|
})),
|
||||||
|
getDesktopShellModeState: () => ({ isFirstRun: false, desktopMode: "local" }),
|
||||||
|
}));
|
||||||
vi.mock("../local-server.js", () => ({ DesktopLocalServerManager: vi.fn(() => mocks.localServerManager) }));
|
vi.mock("../local-server.js", () => ({ DesktopLocalServerManager: vi.fn(() => mocks.localServerManager) }));
|
||||||
|
|
||||||
describe("main local mode", () => {
|
describe("main local mode", () => {
|
||||||
|
|||||||
@@ -100,6 +100,55 @@ vi.mock("electron", () => ({
|
|||||||
shell: mocks.shell,
|
shell: mocks.shell,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mainDeps = vi.hoisted(() => {
|
||||||
|
const start = vi.fn(async () => undefined);
|
||||||
|
const stop = vi.fn(async () => undefined);
|
||||||
|
const getState = vi.fn(() => ({ status: "idle", error: null }));
|
||||||
|
const getPort = vi.fn(() => 0);
|
||||||
|
return {
|
||||||
|
registerIpcHandlers: vi.fn(),
|
||||||
|
buildAppMenu: vi.fn(),
|
||||||
|
setupTray: vi.fn(),
|
||||||
|
registerDeepLinkProtocol: vi.fn(),
|
||||||
|
setupDeepLinkHandler: vi.fn(),
|
||||||
|
setupAutoUpdater: vi.fn(),
|
||||||
|
loadWindowState: vi.fn(async () => null),
|
||||||
|
saveWindowState: vi.fn(),
|
||||||
|
readShellSettings: vi.fn(async () => ({
|
||||||
|
desktopMode: null,
|
||||||
|
hasCompletedModeSelection: false,
|
||||||
|
activeProfileId: null,
|
||||||
|
profiles: [],
|
||||||
|
})),
|
||||||
|
DesktopLocalServerManager: vi.fn(() => ({ start, stop, getState, getPort })),
|
||||||
|
start,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../ipc.js", () => ({ registerIpcHandlers: mainDeps.registerIpcHandlers }));
|
||||||
|
vi.mock("../menu.js", () => ({ buildAppMenu: mainDeps.buildAppMenu }));
|
||||||
|
vi.mock("../tray.js", () => ({ setupTray: mainDeps.setupTray }));
|
||||||
|
vi.mock("../deep-link.js", () => ({
|
||||||
|
registerDeepLinkProtocol: mainDeps.registerDeepLinkProtocol,
|
||||||
|
setupDeepLinkHandler: mainDeps.setupDeepLinkHandler,
|
||||||
|
}));
|
||||||
|
vi.mock("../native.js", () => ({
|
||||||
|
DEFAULT_WINDOW_STATE: { width: 1280, height: 900, isMaximized: false },
|
||||||
|
loadWindowState: mainDeps.loadWindowState,
|
||||||
|
saveWindowState: mainDeps.saveWindowState,
|
||||||
|
setupAutoUpdater: mainDeps.setupAutoUpdater,
|
||||||
|
}));
|
||||||
|
vi.mock("../shell-settings.js", () => ({
|
||||||
|
readShellSettings: mainDeps.readShellSettings,
|
||||||
|
getDesktopShellModeState: (settings: { hasCompletedModeSelection: boolean; desktopMode: "local" | "remote" | null }) => ({
|
||||||
|
isFirstRun: !settings.hasCompletedModeSelection || settings.desktopMode === null,
|
||||||
|
desktopMode: settings.desktopMode,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../local-server.js", () => ({
|
||||||
|
DesktopLocalServerManager: mainDeps.DesktopLocalServerManager,
|
||||||
|
}));
|
||||||
|
|
||||||
async function importMainModule() {
|
async function importMainModule() {
|
||||||
return import("../main.ts");
|
return import("../main.ts");
|
||||||
}
|
}
|
||||||
@@ -111,6 +160,12 @@ describe("main process", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
mainDeps.readShellSettings.mockResolvedValue({
|
||||||
|
desktopMode: null,
|
||||||
|
hasCompletedModeSelection: false,
|
||||||
|
activeProfileId: null,
|
||||||
|
profiles: [],
|
||||||
|
});
|
||||||
if (originalDashboardUrl === undefined) {
|
if (originalDashboardUrl === undefined) {
|
||||||
delete process.env.FUSION_DASHBOARD_URL;
|
delete process.env.FUSION_DASHBOARD_URL;
|
||||||
} else {
|
} else {
|
||||||
@@ -202,6 +257,28 @@ describe("main process", () => {
|
|||||||
expect(typeof mainModule.initializeApp).toBe("function");
|
expect(typeof mainModule.initializeApp).toBe("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("initializeApp starts local server only when persisted mode is local and not first run", async () => {
|
||||||
|
mainDeps.readShellSettings.mockResolvedValueOnce({
|
||||||
|
desktopMode: "local",
|
||||||
|
hasCompletedModeSelection: true,
|
||||||
|
activeProfileId: null,
|
||||||
|
profiles: [],
|
||||||
|
});
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
expect(mainDeps.start).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializeApp does not start local server on first run without mode selection", async () => {
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
expect(mainDeps.start).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("createMainWindow registers close and closed handlers", async () => {
|
it("createMainWindow registers close and closed handlers", async () => {
|
||||||
const { createMainWindow } = await importMainModule();
|
const { createMainWindow } = await importMainModule();
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,18 @@ describe("shell-settings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns defaults when file missing", async () => {
|
it("returns defaults when file missing", async () => {
|
||||||
const { readShellSettings } = await import("../shell-settings.ts");
|
const { readShellSettings, getDesktopShellModeState } = await import("../shell-settings.ts");
|
||||||
await expect(readShellSettings()).resolves.toEqual({
|
await expect(readShellSettings()).resolves.toEqual({
|
||||||
desktopMode: "remote",
|
desktopMode: null,
|
||||||
|
hasCompletedModeSelection: false,
|
||||||
activeProfileId: null,
|
activeProfileId: null,
|
||||||
profiles: [],
|
profiles: [],
|
||||||
});
|
});
|
||||||
|
const settings = await readShellSettings();
|
||||||
|
expect(getDesktopShellModeState(settings)).toEqual({
|
||||||
|
isFirstRun: true,
|
||||||
|
desktopMode: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes and reads persisted settings", async () => {
|
it("writes and reads persisted settings", async () => {
|
||||||
@@ -49,6 +55,7 @@ describe("shell-settings", () => {
|
|||||||
|
|
||||||
await writeShellSettings({
|
await writeShellSettings({
|
||||||
desktopMode: "local",
|
desktopMode: "local",
|
||||||
|
hasCompletedModeSelection: true,
|
||||||
activeProfileId: "p1",
|
activeProfileId: "p1",
|
||||||
profiles: [
|
profiles: [
|
||||||
{
|
{
|
||||||
@@ -65,8 +72,33 @@ describe("shell-settings", () => {
|
|||||||
|
|
||||||
await expect(readShellSettings()).resolves.toMatchObject({
|
await expect(readShellSettings()).resolves.toMatchObject({
|
||||||
desktopMode: "local",
|
desktopMode: "local",
|
||||||
|
hasCompletedModeSelection: true,
|
||||||
activeProfileId: "p1",
|
activeProfileId: "p1",
|
||||||
profiles: [{ id: "p1" }],
|
profiles: [{ id: "p1" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("infers completed selection from legacy desktopMode payload", async () => {
|
||||||
|
mockState.content.set("/tmp/fusion/shell-connections.json", JSON.stringify({ desktopMode: "remote" }));
|
||||||
|
const { readShellSettings, getDesktopShellModeState } = await import("../shell-settings.ts");
|
||||||
|
const settings = await readShellSettings();
|
||||||
|
expect(settings.hasCompletedModeSelection).toBe(true);
|
||||||
|
expect(getDesktopShellModeState(settings)).toEqual({
|
||||||
|
isFirstRun: false,
|
||||||
|
desktopMode: "remote",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats invalid persisted mode as first-run", async () => {
|
||||||
|
mockState.content.set(
|
||||||
|
"/tmp/fusion/shell-connections.json",
|
||||||
|
JSON.stringify({ desktopMode: "invalid", hasCompletedModeSelection: true }),
|
||||||
|
);
|
||||||
|
const { readShellSettings, getDesktopShellModeState } = await import("../shell-settings.ts");
|
||||||
|
const settings = await readShellSettings();
|
||||||
|
expect(getDesktopShellModeState(settings)).toEqual({
|
||||||
|
isFirstRun: true,
|
||||||
|
desktopMode: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
20
packages/desktop/src/fusion-internal-modules.d.ts
vendored
Normal file
20
packages/desktop/src/fusion-internal-modules.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
declare module "@fusion/core" {
|
||||||
|
export class TaskStore {
|
||||||
|
constructor(rootDir: string);
|
||||||
|
init(): Promise<void>;
|
||||||
|
watch(): Promise<void>;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@fusion/dashboard" {
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
export function createServer(store: {
|
||||||
|
init(): Promise<void>;
|
||||||
|
watch(): Promise<void>;
|
||||||
|
close(): void;
|
||||||
|
}): {
|
||||||
|
listen(port?: number): Server;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
|
import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
|
||||||
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog } from "./native.js";
|
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog } from "./native.js";
|
||||||
import { type EngineStatus, updateTrayStatus } from "./tray.js";
|
import { type EngineStatus, updateTrayStatus } from "./tray.js";
|
||||||
import { readShellSettings, writeShellSettings, type ShellConnectionProfile } from "./shell-settings.js";
|
import {
|
||||||
|
getDesktopShellModeState,
|
||||||
|
readShellSettings,
|
||||||
|
writeShellSettings,
|
||||||
|
type DesktopShellMode,
|
||||||
|
type ShellConnectionProfile,
|
||||||
|
} from "./shell-settings.js";
|
||||||
import type { DesktopLocalServerState } from "./local-server.js";
|
import type { DesktopLocalServerState } from "./local-server.js";
|
||||||
|
|
||||||
interface ShellConnectionProfileInput {
|
interface ShellConnectionProfileInput {
|
||||||
@@ -13,14 +19,18 @@ interface ShellConnectionProfileInput {
|
|||||||
|
|
||||||
interface ShellConnectionState {
|
interface ShellConnectionState {
|
||||||
host: "desktop-shell";
|
host: "desktop-shell";
|
||||||
desktopMode?: "local" | "remote";
|
desktopModeState: {
|
||||||
|
isFirstRun: boolean;
|
||||||
|
desktopMode: DesktopShellMode | null;
|
||||||
|
};
|
||||||
|
desktopMode?: DesktopShellMode;
|
||||||
activeProfileId: string | null;
|
activeProfileId: string | null;
|
||||||
profiles: ShellConnectionProfile[];
|
profiles: ShellConnectionProfile[];
|
||||||
localServer?: DesktopLocalServerState;
|
localServer?: DesktopLocalServerState;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RegisterIpcOptions {
|
interface RegisterIpcOptions {
|
||||||
onDesktopModeChange?: (mode: "local" | "remote") => Promise<void>;
|
onDesktopModeChange?: (mode: DesktopShellMode) => Promise<void>;
|
||||||
getLocalServerState?: () => DesktopLocalServerState;
|
getLocalServerState?: () => DesktopLocalServerState;
|
||||||
getServerPort?: () => number | undefined;
|
getServerPort?: () => number | undefined;
|
||||||
}
|
}
|
||||||
@@ -39,7 +49,8 @@ function toShellState(
|
|||||||
): ShellConnectionState {
|
): ShellConnectionState {
|
||||||
return {
|
return {
|
||||||
host: "desktop-shell",
|
host: "desktop-shell",
|
||||||
desktopMode: settings.desktopMode,
|
desktopModeState: getDesktopShellModeState(settings),
|
||||||
|
desktopMode: settings.desktopMode ?? undefined,
|
||||||
activeProfileId: settings.activeProfileId,
|
activeProfileId: settings.activeProfileId,
|
||||||
profiles: settings.profiles,
|
profiles: settings.profiles,
|
||||||
localServer: localServerState ?? { status: "idle", error: null },
|
localServer: localServerState ?? { status: "idle", error: null },
|
||||||
@@ -133,9 +144,15 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
|||||||
return emitShellState(mainWindow, options.getLocalServerState);
|
return emitShellState(mainWindow, options.getLocalServerState);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("shell:setDesktopMode", async (_event, mode: "local" | "remote") => {
|
ipcMain.handle("shell:getDesktopModeState", async () => {
|
||||||
|
const settings = await readShellSettings();
|
||||||
|
return getDesktopShellModeState(settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("shell:setDesktopMode", async (_event, mode: DesktopShellMode) => {
|
||||||
const settings = await readShellSettings();
|
const settings = await readShellSettings();
|
||||||
settings.desktopMode = mode;
|
settings.desktopMode = mode;
|
||||||
|
settings.hasCompletedModeSelection = true;
|
||||||
await writeShellSettings(settings);
|
await writeShellSettings(settings);
|
||||||
await options.onDesktopModeChange?.(mode);
|
await options.onDesktopModeChange?.(mode);
|
||||||
return emitShellState(mainWindow, options.getLocalServerState);
|
return emitShellState(mainWindow, options.getLocalServerState);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { setupTray } from "./tray.js";
|
import { setupTray } from "./tray.js";
|
||||||
import { getRendererUrl, getRendererFilePath, isUrlRenderer } from "./renderer.js";
|
import { getRendererUrl, getRendererFilePath, isUrlRenderer } from "./renderer.js";
|
||||||
import { DesktopLocalServerManager } from "./local-server.js";
|
import { DesktopLocalServerManager } from "./local-server.js";
|
||||||
import { readShellSettings } from "./shell-settings.js";
|
import { getDesktopShellModeState, readShellSettings } from "./shell-settings.js";
|
||||||
|
|
||||||
// Re-export for backward compatibility
|
// Re-export for backward compatibility
|
||||||
export { IS_DEVELOPMENT } from "./renderer.js";
|
export { IS_DEVELOPMENT } from "./renderer.js";
|
||||||
@@ -115,7 +115,8 @@ export async function initializeApp(): Promise<void> {
|
|||||||
setupAutoUpdater(createdWindow);
|
setupAutoUpdater(createdWindow);
|
||||||
|
|
||||||
const shellSettings = await readShellSettings();
|
const shellSettings = await readShellSettings();
|
||||||
if (shellSettings.desktopMode === "local") {
|
const desktopModeState = getDesktopShellModeState(shellSettings);
|
||||||
|
if (!desktopModeState.isFirstRun && desktopModeState.desktopMode === "local") {
|
||||||
await localServerManager.start();
|
await localServerManager.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ interface ShellConnectionProfileInput {
|
|||||||
|
|
||||||
interface ShellConnectionState {
|
interface ShellConnectionState {
|
||||||
host: "web" | "mobile-shell" | "desktop-shell";
|
host: "web" | "mobile-shell" | "desktop-shell";
|
||||||
|
desktopModeState?: {
|
||||||
|
isFirstRun: boolean;
|
||||||
|
desktopMode: "local" | "remote" | null;
|
||||||
|
};
|
||||||
desktopMode?: "local" | "remote";
|
desktopMode?: "local" | "remote";
|
||||||
activeProfileId: string | null;
|
activeProfileId: string | null;
|
||||||
profiles: ShellConnectionProfile[];
|
profiles: ShellConnectionProfile[];
|
||||||
@@ -96,6 +100,8 @@ const fusionShell = {
|
|||||||
saveProfile: (profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile> => ipcRenderer.invoke("shell:saveProfile", profile),
|
saveProfile: (profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile> => ipcRenderer.invoke("shell:saveProfile", profile),
|
||||||
deleteProfile: (profileId: string): Promise<void> => ipcRenderer.invoke("shell:deleteProfile", profileId),
|
deleteProfile: (profileId: string): Promise<void> => ipcRenderer.invoke("shell:deleteProfile", profileId),
|
||||||
setActiveProfile: (profileId: string | null): Promise<ShellConnectionState> => ipcRenderer.invoke("shell:setActiveProfile", profileId),
|
setActiveProfile: (profileId: string | null): Promise<ShellConnectionState> => ipcRenderer.invoke("shell:setActiveProfile", profileId),
|
||||||
|
getDesktopModeState: (): Promise<{ isFirstRun: boolean; desktopMode: "local" | "remote" | null }> =>
|
||||||
|
ipcRenderer.invoke("shell:getDesktopModeState"),
|
||||||
setDesktopMode: (mode: "local" | "remote"): Promise<ShellConnectionState> => ipcRenderer.invoke("shell:setDesktopMode", mode),
|
setDesktopMode: (mode: "local" | "remote"): Promise<ShellConnectionState> => ipcRenderer.invoke("shell:setDesktopMode", mode),
|
||||||
startQrScan: (): Promise<{ serverUrl: string; authToken?: string | null }> => ipcRenderer.invoke("shell:startQrScan"),
|
startQrScan: (): Promise<{ serverUrl: string; authToken?: string | null }> => ipcRenderer.invoke("shell:startQrScan"),
|
||||||
openConnectionManager: (): Promise<void> => ipcRenderer.invoke("shell:openConnectionManager"),
|
openConnectionManager: (): Promise<void> => ipcRenderer.invoke("shell:openConnectionManager"),
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DesktopModeChooser } from "../components/DesktopModeChooser";
|
||||||
|
|
||||||
|
describe("DesktopModeChooser", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
it("submits local selection", async () => {
|
||||||
|
const onSelectMode = vi.fn(async () => undefined);
|
||||||
|
render(<DesktopModeChooser onSelectMode={onSelectMode} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Continue with Local Fusion"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSelectMode).toHaveBeenCalledWith("local");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits remote selection", async () => {
|
||||||
|
const onSelectMode = vi.fn(async () => undefined);
|
||||||
|
render(<DesktopModeChooser onSelectMode={onSelectMode} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Continue to Remote Connection"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSelectMode).toHaveBeenCalledWith("remote");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DesktopShellBootstrap } from "../components/DesktopShellBootstrap";
|
||||||
|
|
||||||
|
function TestDashboardApp() {
|
||||||
|
return <div data-testid="dashboard-app">Dashboard</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DesktopShellBootstrap", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis.window as Window & { fusionShell?: unknown; electronAPI?: unknown }).electronAPI = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders chooser on first run", async () => {
|
||||||
|
(globalThis.window as Window & { fusionShell?: unknown }).fusionShell = {
|
||||||
|
getDesktopModeState: vi.fn(async () => ({ isFirstRun: true, desktopMode: null })),
|
||||||
|
setDesktopMode: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DesktopShellBootstrap DashboardApp={TestDashboardApp} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("desktop-mode-chooser")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes first-run local selection into dashboard mount", async () => {
|
||||||
|
const setDesktopMode = vi.fn(async () => undefined);
|
||||||
|
(globalThis.window as Window & { fusionShell?: unknown }).fusionShell = {
|
||||||
|
getDesktopModeState: vi.fn(async () => ({ isFirstRun: true, desktopMode: null })),
|
||||||
|
setDesktopMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DesktopShellBootstrap DashboardApp={TestDashboardApp} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("desktop-mode-chooser")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Continue with Local Fusion"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(setDesktopMode).toHaveBeenCalledWith("local");
|
||||||
|
expect(screen.getByTestId("dashboard-app")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders dashboard in local mode", async () => {
|
||||||
|
(globalThis.window as Window & { fusionShell?: unknown }).fusionShell = {
|
||||||
|
getDesktopModeState: vi.fn(async () => ({ isFirstRun: false, desktopMode: "local" })),
|
||||||
|
setDesktopMode: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DesktopShellBootstrap DashboardApp={TestDashboardApp} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("dashboard-app")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders dashboard app in remote mode so shell onboarding can open", async () => {
|
||||||
|
(globalThis.window as Window & { fusionShell?: unknown }).fusionShell = {
|
||||||
|
getDesktopModeState: vi.fn(async () => ({ isFirstRun: false, desktopMode: "remote" })),
|
||||||
|
setDesktopMode: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DesktopShellBootstrap DashboardApp={TestDashboardApp} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("dashboard-app")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@ export interface ApiResponsePayload {
|
|||||||
|
|
||||||
export interface ElectronApiLike {
|
export interface ElectronApiLike {
|
||||||
invoke?: (channel: string, payload?: unknown) => Promise<unknown>;
|
invoke?: (channel: string, payload?: unknown) => Promise<unknown>;
|
||||||
getServerPort?: () => Promise<number>;
|
getServerPort?: () => Promise<number | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WindowLike {
|
export interface WindowLike {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.desktop-mode-chooser {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-mode-chooser__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-mode-chooser__subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-mode-chooser__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import "./DesktopModeChooser.css";
|
||||||
|
|
||||||
|
export type DesktopModeChoice = "local" | "remote";
|
||||||
|
|
||||||
|
interface DesktopModeChooserProps {
|
||||||
|
onSelectMode: (mode: DesktopModeChoice) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopModeChooser({ onSelectMode }: DesktopModeChooserProps) {
|
||||||
|
const [pendingMode, setPendingMode] = useState<DesktopModeChoice | null>(null);
|
||||||
|
|
||||||
|
const handleSelect = async (mode: DesktopModeChoice) => {
|
||||||
|
setPendingMode(mode);
|
||||||
|
try {
|
||||||
|
await onSelectMode(mode);
|
||||||
|
} finally {
|
||||||
|
setPendingMode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="desktop-mode-chooser" data-testid="desktop-mode-chooser">
|
||||||
|
<h1 className="desktop-mode-chooser__title">How do you want to run Fusion?</h1>
|
||||||
|
<p className="desktop-mode-chooser__subtitle">
|
||||||
|
Run Fusion locally in this app, or continue with remote server setup.
|
||||||
|
</p>
|
||||||
|
<div className="desktop-mode-chooser__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void handleSelect("local")}
|
||||||
|
disabled={pendingMode !== null}
|
||||||
|
>
|
||||||
|
Continue with Local Fusion
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => void handleSelect("remote")}
|
||||||
|
disabled={pendingMode !== null}
|
||||||
|
>
|
||||||
|
Continue to Remote Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { DesktopWrapper } from "./DesktopWrapper";
|
||||||
|
import { DesktopModeChooser, type DesktopModeChoice } from "./DesktopModeChooser";
|
||||||
|
|
||||||
|
interface DesktopModeState {
|
||||||
|
isFirstRun: boolean;
|
||||||
|
desktopMode: DesktopModeChoice | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FusionShellWithModeApi {
|
||||||
|
getDesktopModeState: () => Promise<DesktopModeState>;
|
||||||
|
setDesktopMode: (mode: DesktopModeChoice) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFusionShell(): FusionShellWithModeApi | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = (window as Window & { fusionShell?: Partial<FusionShellWithModeApi> }).fusionShell;
|
||||||
|
if (!api?.getDesktopModeState || !api?.setDesktopMode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return api as FusionShellWithModeApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopShellBootstrap({ DashboardApp }: { DashboardApp: React.ComponentType }) {
|
||||||
|
const fusionShell = useMemo(() => getFusionShell(), []);
|
||||||
|
const [modeState, setModeState] = useState<DesktopModeState | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fusionShell) {
|
||||||
|
setModeState({ isFirstRun: false, desktopMode: "local" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void fusionShell.getDesktopModeState().then((state) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setModeState(state);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setModeState({ isFirstRun: false, desktopMode: "local" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fusionShell]);
|
||||||
|
|
||||||
|
const handleModeSelect = async (mode: DesktopModeChoice) => {
|
||||||
|
if (fusionShell) {
|
||||||
|
await fusionShell.setDesktopMode(mode);
|
||||||
|
}
|
||||||
|
setModeState({ isFirstRun: false, desktopMode: mode });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!modeState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeState.isFirstRun) {
|
||||||
|
return (
|
||||||
|
<DesktopWrapper>
|
||||||
|
<DesktopModeChooser onSelectMode={handleModeSelect} />
|
||||||
|
</DesktopWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DesktopWrapper>
|
||||||
|
<DashboardApp />
|
||||||
|
</DesktopWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@ export function useDeepLink(): UseDeepLinkResult {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = electronAPI.onDeepLink((rawLink: string) => {
|
const unsubscribe = electronAPI.onDeepLink((deepLinkPayload) => {
|
||||||
|
const rawLink = typeof deepLinkPayload === "string" ? deepLinkPayload : deepLinkPayload.raw;
|
||||||
const parsed = parseDeepLink(rawLink);
|
const parsed = parseDeepLink(rawLink);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
setLastDeepLink(parsed);
|
setLastDeepLink(parsed);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { StrictMode, useEffect, useState } from "react";
|
import React, { StrictMode, useEffect, useState } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { DesktopWrapper } from "./components/DesktopWrapper";
|
import { DesktopShellBootstrap } from "./components/DesktopShellBootstrap";
|
||||||
|
|
||||||
const dashboardStylesModulePath = "../../../../dashboard/app/styles.css";
|
const dashboardStylesModulePath = "../../../../dashboard/app/styles.css";
|
||||||
const dashboardAppModulePath = "../../../../dashboard/app/App";
|
const dashboardAppModulePath = "../../../../dashboard/app/App";
|
||||||
@@ -35,11 +35,7 @@ function RendererApp() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <DesktopShellBootstrap DashboardApp={AppComponent} />;
|
||||||
<DesktopWrapper>
|
|
||||||
<AppComponent />
|
|
||||||
</DesktopWrapper>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|||||||
@@ -20,19 +20,13 @@ export interface ElectronApiResponsePayload {
|
|||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
invoke?: (channel: string, payload?: unknown) => Promise<unknown>;
|
invoke?: (channel: string, payload?: unknown) => Promise<unknown>;
|
||||||
apiRequest?: (method: string, path: string, body?: unknown) => Promise<ElectronApiResponsePayload>;
|
apiRequest?: (method: string, path: string, body?: unknown) => Promise<unknown>;
|
||||||
getServerPort?: () => Promise<number>;
|
getServerPort?: () => Promise<number | undefined>;
|
||||||
windowControl?: (action: WindowControlAction) => Promise<boolean | void>;
|
windowControl?: (action: WindowControlAction) => Promise<boolean | void>;
|
||||||
onUpdateAvailable?: (callback: (info: Record<string, unknown>) => void) => (() => void) | void;
|
onUpdateAvailable?: (callback: (info: Record<string, unknown>) => void) => (() => void) | void;
|
||||||
installUpdate?: () => Promise<void>;
|
installUpdate?: () => Promise<void>;
|
||||||
onDeepLink?: (callback: (url: string) => void) => (() => void) | void;
|
onDeepLink?: (callback: (result: { type: "task" | "project" | "unknown"; id: string; raw: string } | string) => void) => (() => void) | void;
|
||||||
getPlatform?: () => Promise<DesktopPlatform>;
|
getPlatform?: () => Promise<DesktopPlatform>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
electronAPI?: ElectronAPI;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -12,14 +12,23 @@ export interface ShellConnectionProfile {
|
|||||||
lastUsedAt?: string | null;
|
lastUsedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DesktopShellMode = "local" | "remote";
|
||||||
|
|
||||||
|
export interface DesktopShellModeState {
|
||||||
|
isFirstRun: boolean;
|
||||||
|
desktopMode: DesktopShellMode | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopShellSettings {
|
export interface DesktopShellSettings {
|
||||||
desktopMode: "local" | "remote";
|
desktopMode: DesktopShellMode | null;
|
||||||
|
hasCompletedModeSelection: boolean;
|
||||||
activeProfileId: string | null;
|
activeProfileId: string | null;
|
||||||
profiles: ShellConnectionProfile[];
|
profiles: ShellConnectionProfile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: DesktopShellSettings = {
|
const DEFAULT_SETTINGS: DesktopShellSettings = {
|
||||||
desktopMode: "remote",
|
desktopMode: null,
|
||||||
|
hasCompletedModeSelection: false,
|
||||||
activeProfileId: null,
|
activeProfileId: null,
|
||||||
profiles: [],
|
profiles: [],
|
||||||
};
|
};
|
||||||
@@ -28,16 +37,28 @@ function getSettingsPath(): string {
|
|||||||
return join(app.getPath("userData"), "shell-connections.json");
|
return join(app.getPath("userData"), "shell-connections.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDesktopMode(value: unknown): DesktopShellMode | null {
|
||||||
|
if (value === "local" || value === "remote") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalize(input: unknown): DesktopShellSettings {
|
function normalize(input: unknown): DesktopShellSettings {
|
||||||
if (!input || typeof input !== "object") {
|
if (!input || typeof input !== "object") {
|
||||||
return { ...DEFAULT_SETTINGS };
|
return { ...DEFAULT_SETTINGS };
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = input as Partial<DesktopShellSettings>;
|
const candidate = input as Partial<DesktopShellSettings>;
|
||||||
|
const desktopMode = normalizeDesktopMode(candidate.desktopMode);
|
||||||
|
const inferredCompleted = desktopMode !== null;
|
||||||
return {
|
return {
|
||||||
desktopMode: candidate.desktopMode === "local" ? "local" : "remote",
|
desktopMode,
|
||||||
|
hasCompletedModeSelection: typeof candidate.hasCompletedModeSelection === "boolean" ? candidate.hasCompletedModeSelection : inferredCompleted,
|
||||||
activeProfileId: typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null,
|
activeProfileId: typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null,
|
||||||
profiles: Array.isArray(candidate.profiles) ? candidate.profiles.filter((item) => item && typeof item === "object") as ShellConnectionProfile[] : [],
|
profiles: Array.isArray(candidate.profiles)
|
||||||
|
? (candidate.profiles.filter((item) => item && typeof item === "object") as ShellConnectionProfile[])
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +71,20 @@ export async function readShellSettings(): Promise<DesktopShellSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDesktopShellModeState(settings: DesktopShellSettings): DesktopShellModeState {
|
||||||
|
if (!settings.hasCompletedModeSelection || settings.desktopMode === null) {
|
||||||
|
return {
|
||||||
|
isFirstRun: true,
|
||||||
|
desktopMode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFirstRun: false,
|
||||||
|
desktopMode: settings.desktopMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function writeShellSettings(settings: DesktopShellSettings): Promise<void> {
|
export async function writeShellSettings(settings: DesktopShellSettings): Promise<void> {
|
||||||
const path = getSettingsPath();
|
const path = getSettingsPath();
|
||||||
const temp = `${path}.tmp`;
|
const temp = `${path}.tmp`;
|
||||||
|
|||||||
9
packages/desktop/src/types.d.ts
vendored
9
packages/desktop/src/types.d.ts
vendored
@@ -71,6 +71,10 @@ export interface ShellConnectionProfileInput {
|
|||||||
|
|
||||||
export interface ShellConnectionState {
|
export interface ShellConnectionState {
|
||||||
host: "web" | "mobile-shell" | "desktop-shell";
|
host: "web" | "mobile-shell" | "desktop-shell";
|
||||||
|
desktopModeState?: {
|
||||||
|
isFirstRun: boolean;
|
||||||
|
desktopMode: "local" | "remote" | null;
|
||||||
|
};
|
||||||
desktopMode?: "local" | "remote";
|
desktopMode?: "local" | "remote";
|
||||||
activeProfileId: string | null;
|
activeProfileId: string | null;
|
||||||
profiles: ShellConnectionProfile[];
|
profiles: ShellConnectionProfile[];
|
||||||
@@ -87,6 +91,7 @@ export interface FusionShellApi {
|
|||||||
saveProfile(profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile>;
|
saveProfile(profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile>;
|
||||||
deleteProfile(profileId: string): Promise<void>;
|
deleteProfile(profileId: string): Promise<void>;
|
||||||
setActiveProfile(profileId: string | null): Promise<ShellConnectionState>;
|
setActiveProfile(profileId: string | null): Promise<ShellConnectionState>;
|
||||||
|
getDesktopModeState(): Promise<{ isFirstRun: boolean; desktopMode: "local" | "remote" | null }>;
|
||||||
setDesktopMode(mode: "local" | "remote"): Promise<ShellConnectionState>;
|
setDesktopMode(mode: "local" | "remote"): Promise<ShellConnectionState>;
|
||||||
startQrScan(): Promise<{ serverUrl: string; authToken?: string | null }>;
|
startQrScan(): Promise<{ serverUrl: string; authToken?: string | null }>;
|
||||||
openConnectionManager(): Promise<void>;
|
openConnectionManager(): Promise<void>;
|
||||||
@@ -95,8 +100,8 @@ export interface FusionShellApi {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
fusionAPI: FusionAPI;
|
fusionAPI?: FusionAPI;
|
||||||
electronAPI: FusionAPI;
|
electronAPI?: FusionAPI;
|
||||||
fusionShell?: FusionShellApi;
|
fusionShell?: FusionShellApi;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user