feat(FN-3398): update desktop README with multi-project setup and troublesh

The merge lands Step 7 of FN-3398, adding documentation and delivery artifacts across the mobile and desktop packages with updated READMEs, mobile-specific docs, and architecture references.

Fusion-Task-Id: FN-3398
This commit is contained in:
Fusion
2026-05-04 21:41:52 -07:00
committed by gsxdsm
parent 9699c3f2b6
commit 3feeb9026a
43 changed files with 2025 additions and 398 deletions

View File

@@ -59,9 +59,18 @@ getRendererUrl() // Returns URL or file:// path
getRendererFilePath() // Returns absolute file path for loadFile()
```
## First-run Shell Onboarding (Desktop)
Desktop now boots through a shell-level onboarding gate before dashboard onboarding when no usable shell connection state exists.
- **First run choice:** users choose **Local Fusion (bundled runtime)** or **Remote Server**.
- **Desktop mode restore:** last-used mode is persisted and restored 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.
- **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.
## IPC Channel Reference
`src/ipc.ts` registers the renderer ↔ main process bridge used by `window.fusionAPI`.
`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).
### Renderer → Main (`ipcRenderer.invoke`)
@@ -86,6 +95,16 @@ getRendererFilePath() // Returns absolute file path for loadFile()
| `update-available` | main → renderer | update info object (includes `version`) |
| `update-downloaded` | main → renderer | no payload is currently forwarded by preload |
## Local Bundled Runtime Lifecycle
Desktop local mode uses an in-process runtime manager (`src/local-server.ts`) that mirrors the CLI desktop server pattern:
- creates `TaskStore`, calls `init()` and `watch()`
- creates the dashboard server with `createServer(store)`
- listens on an ephemeral port (`0`, never `4040`)
- reports `idle | starting | ready | error` local runtime state via `window.fusionShell`
- starts automatically when desktop mode is `local`, stops on remote switch and app shutdown
## Main Process Lifecycle
`src/main.ts` orchestrates module startup in this order:
@@ -115,20 +134,26 @@ getRendererFilePath() // Returns absolute file path for loadFile()
- Tray instance is destroyed (`tray.destroy()`)
- `mainWindow` is nulled on `closed` for clean re-creation on macOS `activate`
## Preload API (`window.fusionAPI`)
## Preload APIs (`window.electronAPI` and `window.fusionShell`)
`src/preload.ts` exposes a safe, context-isolated bridge:
`src/preload.ts` exposes safe, context-isolated bridges:
- Window control: `minimize()`, `maximize()`, `close()`, `isMaximized()`
- App/system: `getSystemInfo()`, `checkForUpdates()`, `getServerPort()`
- Tray: `updateTrayStatus(status)`
- Native dialogs: `showExportDialog()`, `showImportDialog()`
- Event subscriptions (return unsubscribe functions):
- `onDeepLink(callback)`
- `onUpdateAvailable(callback)`
- `onUpdateDownloaded(callback)`
- `window.electronAPI`
- Window control: `minimize()`, `maximize()`, `close()`, `isMaximized()`
- App/system: `getSystemInfo()`, `checkForUpdates()`, `getServerPort()`
- Tray: `updateTrayStatus(status)`
- Native dialogs: `showExportDialog()`, `showImportDialog()`
- Event subscriptions (return unsubscribe functions):
- `onDeepLink(callback)`
- `onUpdateAvailable(callback)`
- `onUpdateDownloaded(callback)`
- `window.fusionShell`
- `getState()`, `listProfiles()`, `saveProfile()`, `deleteProfile()`
- `setActiveProfile()`, `setDesktopMode()`
- `startQrScan()`, `openConnectionManager()`, `subscribe(listener)`
- `window.fusionAPI` remains as a backward-compatible alias of `window.electronAPI`.
All preload typings are declared in `src/types.d.ts` (`FusionAPI`, `SystemInfo`, `UpdateCheckResult`, `DeepLinkResult`).
All preload typings are declared in `src/types.d.ts`.
## Module Integration Overview

View File

@@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => {
const showExportSettingsDialog = vi.fn();
const showImportSettingsDialog = vi.fn();
const setupAutoUpdater = vi.fn();
const readShellSettings = vi.fn(async () => ({ desktopMode: "remote", activeProfileId: null, profiles: [] }));
const writeShellSettings = vi.fn(async () => undefined);
return {
ipcMain,
@@ -26,6 +28,8 @@ const mocks = vi.hoisted(() => {
showExportSettingsDialog,
showImportSettingsDialog,
setupAutoUpdater,
readShellSettings,
writeShellSettings,
};
});
@@ -44,6 +48,11 @@ vi.mock("../native.js", () => ({
setupAutoUpdater: mocks.setupAutoUpdater,
}));
vi.mock("../shell-settings.js", () => ({
readShellSettings: mocks.readShellSettings,
writeShellSettings: mocks.writeShellSettings,
}));
function createWindowMock() {
return {
minimize: vi.fn(),
@@ -51,6 +60,7 @@ function createWindowMock() {
unmaximize: vi.fn(),
close: vi.fn(),
isMaximized: vi.fn(() => false),
webContents: { send: vi.fn() },
};
}
@@ -61,11 +71,11 @@ function createTrayMock() {
};
}
async function registerHandlers() {
async function registerHandlers(options: Record<string, unknown> = {}) {
const { registerIpcHandlers } = await import("../ipc.ts");
const window = createWindowMock();
const tray = createTrayMock();
registerIpcHandlers(window as never, tray as never);
registerIpcHandlers(window as never, tray as never, options as never);
return { window, tray };
}
@@ -74,169 +84,34 @@ describe("ipc handlers", () => {
vi.clearAllMocks();
vi.resetModules();
mocks.ipcHandlers.clear();
mocks.app.getVersion.mockReturnValue("1.2.3");
mocks.setupAutoUpdater.mockImplementation(() => undefined);
mocks.showExportSettingsDialog.mockResolvedValue(null);
mocks.showImportSettingsDialog.mockResolvedValue(null);
});
it("registers all expected channels", async () => {
it("registers shell channels", async () => {
await registerHandlers();
const channels = new Set(mocks.ipcMain.handle.mock.calls.map(([channel]) => channel));
expect(channels).toEqual(new Set([
"window:minimize",
"window:maximize",
"window:close",
"window:isMaximized",
"app:getSystemInfo",
"app:checkForUpdates",
"app:getServerPort",
"tray:updateStatus",
"native:showExportDialog",
"native:showImportDialog",
]));
expect(channels.has("shell:getState")).toBe(true);
expect(channels.has("shell:saveProfile")).toBe(true);
expect(channels.has("shell:setDesktopMode")).toBe(true);
expect(channels.has("platform:get")).toBe(true);
});
it("window:minimize calls mainWindow.minimize", async () => {
const { window } = await registerHandlers();
const handler = mocks.ipcHandlers.get("window:minimize");
await handler?.({});
expect(window.minimize).toHaveBeenCalledTimes(1);
});
it("window:maximize maximizes when currently unmaximized", async () => {
const { window } = await registerHandlers();
window.isMaximized.mockReturnValue(false);
const handler = mocks.ipcHandlers.get("window:maximize");
const result = await handler?.({});
expect(window.maximize).toHaveBeenCalledTimes(1);
expect(window.unmaximize).not.toHaveBeenCalled();
expect(result).toBe(true);
});
it("window:maximize restores when currently maximized", async () => {
const { window } = await registerHandlers();
window.isMaximized.mockReturnValue(true);
const handler = mocks.ipcHandlers.get("window:maximize");
const result = await handler?.({});
expect(window.unmaximize).toHaveBeenCalledTimes(1);
expect(window.maximize).not.toHaveBeenCalled();
expect(result).toBe(false);
});
it("window:close calls mainWindow.close", async () => {
const { window } = await registerHandlers();
const handler = mocks.ipcHandlers.get("window:close");
await handler?.({});
expect(window.close).toHaveBeenCalledTimes(1);
});
it("window:isMaximized returns current maximized state", async () => {
const { window } = await registerHandlers();
window.isMaximized.mockReturnValue(true);
const handler = mocks.ipcHandlers.get("window:isMaximized");
const result = await handler?.({});
expect(result).toBe(true);
});
it("app:getSystemInfo returns process and app metadata", async () => {
it("shell:getState returns desktop shell state", async () => {
await registerHandlers();
const handler = mocks.ipcHandlers.get("app:getSystemInfo");
const handler = mocks.ipcHandlers.get("shell:getState");
const result = await handler?.({});
expect(result).toEqual({
platform: process.platform,
arch: process.arch,
electronVersion: process.versions.electron,
nodeVersion: process.versions.node,
appVersion: "1.2.3",
});
expect(result).toMatchObject({ host: "desktop-shell", desktopMode: "remote" });
});
it("app:checkForUpdates calls setupAutoUpdater and returns checking", async () => {
const { window } = await registerHandlers();
it("shell:setDesktopMode persists mode and emits state", async () => {
const onDesktopModeChange = vi.fn(async () => undefined);
const { window } = await registerHandlers({ onDesktopModeChange });
const handler = mocks.ipcHandlers.get("shell:setDesktopMode");
await handler?.({}, "local");
const handler = mocks.ipcHandlers.get("app:checkForUpdates");
const result = await handler?.({});
expect(mocks.setupAutoUpdater).toHaveBeenCalledWith(window);
expect(result).toEqual({ status: "checking" });
});
it("app:checkForUpdates returns error when updater throws", async () => {
await registerHandlers();
mocks.setupAutoUpdater.mockImplementationOnce(() => {
throw new Error("updater failed");
});
const handler = mocks.ipcHandlers.get("app:checkForUpdates");
const result = await handler?.({});
expect(result).toEqual({ status: "error", error: "updater failed" });
});
it("native:showExportDialog calls showExportSettingsDialog with mainWindow", async () => {
const { window } = await registerHandlers();
mocks.showExportSettingsDialog.mockResolvedValueOnce("/path/to/file.json");
const handler = mocks.ipcHandlers.get("native:showExportDialog");
const result = await handler?.({});
expect(mocks.showExportSettingsDialog).toHaveBeenCalledWith(window);
expect(result).toBe("/path/to/file.json");
});
it("native:showImportDialog calls showImportSettingsDialog with mainWindow", async () => {
const { window } = await registerHandlers();
mocks.showImportSettingsDialog.mockResolvedValueOnce(null);
const handler = mocks.ipcHandlers.get("native:showImportDialog");
const result = await handler?.({});
expect(mocks.showImportSettingsDialog).toHaveBeenCalledWith(window);
expect(result).toBeNull();
});
it("tray:updateStatus forwards status and tray instance", async () => {
const { tray } = await registerHandlers();
const handler = mocks.ipcHandlers.get("tray:updateStatus");
await handler?.({}, "paused");
expect(mocks.updateTrayStatus).toHaveBeenCalledWith(tray, "paused");
});
it("app:getServerPort returns port from environment", async () => {
process.env.FUSION_SERVER_PORT = "4545";
await registerHandlers();
const handler = mocks.ipcHandlers.get("app:getServerPort");
const result = await handler?.({});
expect(result).toBe(4545);
delete process.env.FUSION_SERVER_PORT;
});
it("app:getServerPort returns undefined when env var not set", async () => {
delete process.env.FUSION_SERVER_PORT;
await registerHandlers();
const handler = mocks.ipcHandlers.get("app:getServerPort");
const result = await handler?.({});
expect(result).toBeUndefined();
expect(mocks.writeShellSettings).toHaveBeenCalled();
expect(onDesktopModeChange).toHaveBeenCalledWith("local");
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
});
});

View File

@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
type Handler = (...args: unknown[]) => void;
class SimpleEmitter {
private listeners = new Map<string, Handler[]>();
on(event: string, handler: Handler) {
const current = this.listeners.get(event) ?? [];
current.push(handler);
this.listeners.set(event, current);
return this;
}
once(event: string, handler: Handler) {
const wrapped: Handler = (...args) => {
this.removeListener(event, wrapped);
handler(...args);
};
return this.on(event, wrapped);
}
removeListener(event: string, handler: Handler) {
const current = this.listeners.get(event) ?? [];
this.listeners.set(event, current.filter((item) => item !== handler));
return this;
}
emit(event: string, ...args: unknown[]) {
const current = this.listeners.get(event) ?? [];
for (const handler of current) {
handler(...args);
}
}
}
const store = {
init: vi.fn(async () => undefined),
watch: vi.fn(async () => undefined),
close: vi.fn(),
};
class TaskStore {
constructor(_rootDir: string) {}
init = store.init;
watch = store.watch;
close = store.close;
}
const server = Object.assign(new SimpleEmitter(), {
address: vi.fn(() => ({ port: 4545 })),
close: vi.fn((cb: () => void) => cb()),
});
const listen = vi.fn(() => {
queueMicrotask(() => server.emit("listening"));
return server;
});
const createServer = vi.fn(() => ({ listen }));
return { TaskStore, createServer, store, listen };
});
vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore }));
vi.mock("@fusion/dashboard", () => ({ createServer: mocks.createServer }));
describe("DesktopLocalServerManager", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("starts local runtime and exposes port", async () => {
const { DesktopLocalServerManager } = await import("../local-server.ts");
const manager = new DesktopLocalServerManager("/repo");
const runtime = await manager.start();
expect(runtime.port).toBe(4545);
expect(manager.getPort()).toBe(4545);
expect(manager.getState().status).toBe("ready");
});
it("stops local runtime and resets state", async () => {
const { DesktopLocalServerManager } = await import("../local-server.ts");
const manager = new DesktopLocalServerManager("/repo");
await manager.start();
await manager.stop();
expect(mocks.store.close).toHaveBeenCalled();
expect(manager.getState().status).toBe("idle");
expect(manager.getPort()).toBeUndefined();
});
it("sets error state when startup fails", async () => {
mocks.store.init.mockRejectedValueOnce(new Error("init failed"));
const { DesktopLocalServerManager } = await import("../local-server.ts");
const manager = new DesktopLocalServerManager("/repo");
await expect(manager.start()).rejects.toThrow("init failed");
expect(manager.getState()).toMatchObject({ status: "error", error: "init failed" });
});
it("returns existing runtime when start is called twice", async () => {
const { DesktopLocalServerManager } = await import("../local-server.ts");
const manager = new DesktopLocalServerManager("/repo");
const first = await manager.start();
const second = await manager.start();
expect(first).toBe(second);
expect(mocks.listen).toHaveBeenCalledTimes(1);
});
});

View File

@@ -280,7 +280,7 @@ describe("main integration", () => {
const [{ instance }] = mocks.windowInstances;
const [trayInstance] = mocks.trayInstances;
expect(mocks.registerIpcHandlers).toHaveBeenCalledWith(instance, trayInstance);
expect(mocks.registerIpcHandlers).toHaveBeenCalledWith(instance, trayInstance, expect.any(Object));
});
it("window close hides to tray when app is not quitting", async () => {

View File

@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const appHandlers = new Map<string, (...args: unknown[]) => void>();
const app = {
whenReady: vi.fn(async () => undefined),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
appHandlers.set(event, handler);
return app;
}),
quit: vi.fn(),
};
const browserWindow = {
on: vi.fn(),
loadURL: vi.fn(),
loadFile: vi.fn(),
isDestroyed: vi.fn(() => false),
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 800, height: 600 })),
isMaximized: vi.fn(() => false),
hide: vi.fn(),
maximize: vi.fn(),
webContents: { send: vi.fn() },
};
const BrowserWindow = vi.fn(() => browserWindow);
const Tray = vi.fn(() => ({ destroy: vi.fn() }));
const localServerManager = {
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
getState: vi.fn(() => ({ status: "idle", error: null })),
getPort: vi.fn(() => undefined),
};
return { app, appHandlers, BrowserWindow, Tray, browserWindow, localServerManager };
});
vi.mock("electron", () => ({
app: mocks.app,
BrowserWindow: mocks.BrowserWindow,
Tray: mocks.Tray,
nativeImage: { createEmpty: vi.fn(() => ({})) },
}));
vi.mock("../renderer.js", () => ({ isUrlRenderer: vi.fn(() => true), getRendererUrl: vi.fn(() => "http://localhost"), getRendererFilePath: vi.fn(() => "index.html") }));
vi.mock("../menu.js", () => ({ buildAppMenu: vi.fn() }));
vi.mock("../tray.js", () => ({ setupTray: 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("../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("../local-server.js", () => ({ DesktopLocalServerManager: vi.fn(() => mocks.localServerManager) }));
describe("main local mode", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.appHandlers.clear();
});
it("starts local server manager when restored desktop mode is local", async () => {
const { initializeApp } = await import("../main.ts");
await initializeApp();
expect(mocks.localServerManager.start).toHaveBeenCalled();
});
});

View File

@@ -23,26 +23,8 @@ async function importPreloadModule() {
await import("../preload.ts");
}
function getFusionApi() {
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
([name]) => name === "fusionAPI",
) as [string, {
minimize: () => Promise<void>;
maximize: () => Promise<boolean>;
close: () => Promise<void>;
isMaximized: () => Promise<boolean>;
getSystemInfo: () => Promise<unknown>;
checkForUpdates: () => Promise<unknown>;
getServerPort: () => Promise<number | undefined>;
updateTrayStatus: (status: string) => Promise<void>;
showExportDialog: () => Promise<string | null>;
showImportDialog: () => Promise<string | null>;
onDeepLink: (callback: (result: unknown) => void) => () => void;
onUpdateAvailable: (callback: (info: { version: string }) => void) => () => void;
onUpdateDownloaded: (callback: () => void) => () => void;
}] | undefined;
return call?.[1];
function getExposed<T = unknown>(name: string): T | undefined {
return mocks.contextBridge.exposeInMainWorld.mock.calls.find(([key]) => key === name)?.[1] as T | undefined;
}
describe("preload", () => {
@@ -51,153 +33,32 @@ describe("preload", () => {
vi.resetModules();
});
it("contextBridge.exposeInMainWorld is called with fusionAPI", async () => {
it("exposes electronAPI and fusionShell", async () => {
await importPreloadModule();
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
"fusionAPI",
expect.any(Object),
);
expect(getExposed("electronAPI")).toBeTruthy();
expect(getExposed("fusionAPI")).toBeTruthy();
expect(getExposed("fusionShell")).toBeTruthy();
});
it("minimize invokes window:minimize", async () => {
it("electronAPI delegates getServerPort to IPC", async () => {
await importPreloadModule();
const api = getExposed<{ getServerPort: () => Promise<number | undefined> }>("electronAPI");
const api = getFusionApi();
await api?.minimize();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:minimize");
});
it("maximize invokes window:maximize", async () => {
await importPreloadModule();
const api = getFusionApi();
await api?.maximize();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:maximize");
});
it("close invokes window:close", async () => {
await importPreloadModule();
const api = getFusionApi();
await api?.close();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:close");
});
it("isMaximized invokes window:isMaximized", async () => {
await importPreloadModule();
const api = getFusionApi();
await api?.isMaximized();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:isMaximized");
});
it("getSystemInfo invokes app:getSystemInfo", async () => {
await importPreloadModule();
const api = getFusionApi();
await api?.getSystemInfo();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:getSystemInfo");
});
it("checkForUpdates invokes app:checkForUpdates", async () => {
await importPreloadModule();
const api = getFusionApi();
await api?.checkForUpdates();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:checkForUpdates");
});
it("getServerPort invokes app:getServerPort", async () => {
await importPreloadModule();
const api = getFusionApi();
await api?.getServerPort();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:getServerPort");
});
it("updateTrayStatus invokes tray:updateStatus with status argument", async () => {
it("fusionShell subscribes and unsubscribes state listener", async () => {
await importPreloadModule();
const shell = getExposed<{ subscribe: (listener: (state: unknown) => void) => () => void }>("fusionShell");
const api = getFusionApi();
await api?.updateTrayStatus("paused");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("tray:updateStatus", "paused");
});
it("showExportDialog invokes native:showExportDialog", async () => {
await importPreloadModule();
const api = getFusionApi();
await api?.showExportDialog();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("native:showExportDialog");
});
it("showImportDialog invokes native:showImportDialog", async () => {
await importPreloadModule();
const api = getFusionApi();
await api?.showImportDialog();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("native:showImportDialog");
});
it("onDeepLink subscribes to deep-link and returns unsubscribe", async () => {
await importPreloadModule();
const api = getFusionApi();
const callback = vi.fn();
const unsubscribe = api?.onDeepLink(callback);
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("deep-link", expect.any(Function));
const unsubscribe = shell?.subscribe(() => undefined);
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("shell:state", expect.any(Function));
unsubscribe?.();
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
"deep-link",
expect.any(Function),
);
});
it("onUpdateAvailable subscribes to update-available and returns unsubscribe", async () => {
await importPreloadModule();
const api = getFusionApi();
const callback = vi.fn();
const unsubscribe = api?.onUpdateAvailable(callback);
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update-available", expect.any(Function));
unsubscribe?.();
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
"update-available",
expect.any(Function),
);
});
it("onUpdateDownloaded subscribes to update-downloaded and returns unsubscribe", async () => {
await importPreloadModule();
const api = getFusionApi();
const callback = vi.fn();
const unsubscribe = api?.onUpdateDownloaded(callback);
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update-downloaded", expect.any(Function));
unsubscribe?.();
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
"update-downloaded",
expect.any(Function),
);
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("shell:state", expect.any(Function));
});
});

View File

@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockState = vi.hoisted(() => ({
content: new Map<string, string>(),
}));
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/fusion"),
},
}));
vi.mock("node:fs/promises", () => ({
readFile: vi.fn(async (path: string) => {
const value = mockState.content.get(path);
if (!value) {
const err = new Error("ENOENT") as NodeJS.ErrnoException;
err.code = "ENOENT";
throw err;
}
return value;
}),
writeFile: vi.fn(async (path: string, value: string) => {
mockState.content.set(path, value);
}),
rename: vi.fn(async (from: string, to: string) => {
const value = mockState.content.get(from) ?? "";
mockState.content.set(to, value);
}),
}));
describe("shell-settings", () => {
beforeEach(() => {
mockState.content.clear();
vi.resetModules();
});
it("returns defaults when file missing", async () => {
const { readShellSettings } = await import("../shell-settings.ts");
await expect(readShellSettings()).resolves.toEqual({
desktopMode: "remote",
activeProfileId: null,
profiles: [],
});
});
it("writes and reads persisted settings", async () => {
const { writeShellSettings, readShellSettings } = await import("../shell-settings.ts");
await writeShellSettings({
desktopMode: "local",
activeProfileId: "p1",
profiles: [
{
id: "p1",
name: "Local",
serverUrl: "http://127.0.0.1",
authToken: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: null,
},
],
});
await expect(readShellSettings()).resolves.toMatchObject({
desktopMode: "local",
activeProfileId: "p1",
profiles: [{ id: "p1" }],
});
});
});

View File

@@ -1,28 +1,74 @@
import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog } from "./native.js";
import { type EngineStatus, updateTrayStatus } from "./tray.js";
import { readShellSettings, writeShellSettings, type ShellConnectionProfile } from "./shell-settings.js";
import type { DesktopLocalServerState } from "./local-server.js";
export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray): void {
ipcMain.handle("window:minimize", () => {
mainWindow.minimize();
});
interface ShellConnectionProfileInput {
id?: string;
name: string;
serverUrl: string;
authToken?: string | null;
}
interface ShellConnectionState {
host: "desktop-shell";
desktopMode?: "local" | "remote";
activeProfileId: string | null;
profiles: ShellConnectionProfile[];
localServer?: DesktopLocalServerState;
}
interface RegisterIpcOptions {
onDesktopModeChange?: (mode: "local" | "remote") => Promise<void>;
getLocalServerState?: () => DesktopLocalServerState;
getServerPort?: () => number | undefined;
}
function nowIso(): string {
return new Date().toISOString();
}
function createProfileId(): string {
return `profile_${Math.random().toString(36).slice(2, 10)}`;
}
function toShellState(
settings: Awaited<ReturnType<typeof readShellSettings>>,
localServerState?: DesktopLocalServerState,
): ShellConnectionState {
return {
host: "desktop-shell",
desktopMode: settings.desktopMode,
activeProfileId: settings.activeProfileId,
profiles: settings.profiles,
localServer: localServerState ?? { status: "idle", error: null },
};
}
async function emitShellState(
mainWindow: BrowserWindow,
getLocalServerState?: () => DesktopLocalServerState,
): Promise<ShellConnectionState> {
const state = toShellState(await readShellSettings(), getLocalServerState?.());
mainWindow.webContents.send("shell:state", state);
return state;
}
export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, options: RegisterIpcOptions = {}): void {
ipcMain.handle("window:minimize", () => mainWindow.minimize());
ipcMain.handle("window:maximize", () => {
const isCurrentlyMaximized = mainWindow.isMaximized();
if (isCurrentlyMaximized) {
mainWindow.unmaximize();
return false;
}
mainWindow.maximize();
return true;
});
ipcMain.handle("window:close", () => {
mainWindow.close();
});
ipcMain.handle("window:close", () => mainWindow.close());
ipcMain.handle("window:isMaximized", () => mainWindow.isMaximized());
ipcMain.handle("platform:get", () => process.platform);
ipcMain.handle("app:getSystemInfo", () => ({
platform: process.platform,
@@ -37,23 +83,69 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray): void
setupAutoUpdater(mainWindow);
return { status: "checking" as const };
} catch (error) {
return {
status: "error" as const,
error: error instanceof Error ? error.message : String(error),
};
return { status: "error" as const, error: error instanceof Error ? error.message : String(error) };
}
});
ipcMain.handle("tray:updateStatus", (_event, status: EngineStatus) => {
updateTrayStatus(tray, status);
});
ipcMain.handle("tray:updateStatus", (_event, status: EngineStatus) => updateTrayStatus(tray, status));
ipcMain.handle("native:showExportDialog", () => showExportSettingsDialog(mainWindow));
ipcMain.handle("native:showImportDialog", () => showImportSettingsDialog(mainWindow));
ipcMain.handle("app:getServerPort", () => options.getServerPort?.());
// Return the server port from environment variable (set by CLI)
ipcMain.handle("app:getServerPort", () => {
const port = process.env.FUSION_SERVER_PORT;
return port ? parseInt(port, 10) : undefined;
ipcMain.handle("shell:getState", () => readShellSettings().then((settings) => toShellState(settings, options.getLocalServerState?.())));
ipcMain.handle("shell:listProfiles", async () => (await readShellSettings()).profiles);
ipcMain.handle("shell:saveProfile", async (_event, profile: ShellConnectionProfileInput) => {
const settings = await readShellSettings();
const existing = profile.id ? settings.profiles.find((item) => item.id === profile.id) : undefined;
const timestamp = nowIso();
const nextProfile: ShellConnectionProfile = {
id: existing?.id ?? profile.id ?? createProfileId(),
name: profile.name.trim(),
serverUrl: profile.serverUrl.trim().replace(/\/$/, ""),
authToken: profile.authToken ?? null,
createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp,
lastUsedAt: existing?.lastUsedAt ?? null,
};
settings.profiles = existing ? settings.profiles.map((item) => (item.id === existing.id ? nextProfile : item)) : [...settings.profiles, nextProfile];
await writeShellSettings(settings);
await emitShellState(mainWindow, options.getLocalServerState);
return nextProfile;
});
ipcMain.handle("shell:deleteProfile", async (_event, profileId: string) => {
const settings = await readShellSettings();
settings.profiles = settings.profiles.filter((item) => item.id !== profileId);
if (settings.activeProfileId === profileId) settings.activeProfileId = null;
await writeShellSettings(settings);
await emitShellState(mainWindow, options.getLocalServerState);
});
ipcMain.handle("shell:setActiveProfile", async (_event, profileId: string | null) => {
const settings = await readShellSettings();
settings.activeProfileId = profileId && settings.profiles.some((item) => item.id === profileId) ? profileId : null;
settings.profiles = settings.profiles.map((item) =>
item.id === settings.activeProfileId ? { ...item, lastUsedAt: nowIso(), updatedAt: nowIso() } : item,
);
await writeShellSettings(settings);
return emitShellState(mainWindow, options.getLocalServerState);
});
ipcMain.handle("shell:setDesktopMode", async (_event, mode: "local" | "remote") => {
const settings = await readShellSettings();
settings.desktopMode = mode;
await writeShellSettings(settings);
await options.onDesktopModeChange?.(mode);
return emitShellState(mainWindow, options.getLocalServerState);
});
ipcMain.handle("shell:startQrScan", async () => {
throw new Error("QR scanning is not available in desktop shell");
});
ipcMain.handle("shell:openConnectionManager", () => {
mainWindow.webContents.send("shell:open-connection-manager");
});
}

View File

@@ -0,0 +1,90 @@
import type { AddressInfo } from "node:net";
import { once } from "node:events";
import type { Server } from "node:http";
type TaskStoreLike = {
init(): Promise<void>;
watch(): Promise<void>;
close(): void;
};
export interface DesktopLocalRuntime {
store: TaskStoreLike;
server: Server;
port: number;
}
export interface DesktopLocalServerState {
status: "idle" | "starting" | "ready" | "error";
port?: number;
error?: string | null;
}
export class DesktopLocalServerManager {
private runtime: DesktopLocalRuntime | null = null;
private state: DesktopLocalServerState = { status: "idle", error: null };
constructor(private readonly rootDir: string) {}
getState(): DesktopLocalServerState {
return this.state;
}
getPort(): number | undefined {
return this.runtime?.port;
}
async start(): Promise<DesktopLocalRuntime> {
if (this.runtime) {
this.state = { status: "ready", port: this.runtime.port, error: null };
return this.runtime;
}
this.state = { status: "starting", error: null };
try {
const { TaskStore } = await import("@fusion/core");
const { createServer } = await import("@fusion/dashboard");
const store = new TaskStore(this.rootDir) as TaskStoreLike;
await store.init();
await store.watch();
const app = createServer(store);
const server = app.listen(0);
await Promise.race([
once(server, "listening"),
once(server, "error").then(([error]) => {
throw error;
}),
]);
const address = server.address() as AddressInfo | null;
if (!address?.port) {
throw new Error("Failed to resolve local server port");
}
this.runtime = { store, server, port: address.port };
this.state = { status: "ready", port: address.port, error: null };
return this.runtime;
} catch (error) {
this.state = {
status: "error",
error: error instanceof Error ? error.message : String(error),
};
throw error;
}
}
async stop(): Promise<void> {
if (!this.runtime) {
this.state = { status: "idle", error: null };
return;
}
const runtime = this.runtime;
this.runtime = null;
await new Promise<void>((resolve) => runtime.server.close(() => resolve()));
runtime.store.close();
this.state = { status: "idle", error: null };
}
}

View File

@@ -13,6 +13,8 @@ import {
} from "./native.js";
import { setupTray } from "./tray.js";
import { getRendererUrl, getRendererFilePath, isUrlRenderer } from "./renderer.js";
import { DesktopLocalServerManager } from "./local-server.js";
import { readShellSettings } from "./shell-settings.js";
// Re-export for backward compatibility
export { IS_DEVELOPMENT } from "./renderer.js";
@@ -33,6 +35,7 @@ enableSourceMaps();
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let localServerManager: DesktopLocalServerManager | null = null;
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
return app as Electron.App & AppWithQuitFlag;
@@ -88,14 +91,34 @@ export async function initializeApp(): Promise<void> {
appName: "Fusion",
});
localServerManager = new DesktopLocalServerManager(process.cwd());
tray = new Tray(nativeImage.createEmpty());
setupTray(createdWindow, tray);
registerIpcHandlers(createdWindow, tray);
registerIpcHandlers(createdWindow, tray, {
onDesktopModeChange: async (mode) => {
if (!localServerManager) {
return;
}
if (mode === "local") {
await localServerManager.start();
} else {
await localServerManager.stop();
}
},
getLocalServerState: () => localServerManager?.getState() ?? { status: "idle", error: null },
getServerPort: () => localServerManager?.getPort(),
});
registerDeepLinkProtocol();
setupDeepLinkHandler(createdWindow);
setupAutoUpdater(createdWindow);
const shellSettings = await readShellSettings();
if (shellSettings.desktopMode === "local") {
await localServerManager.start();
}
if (state?.isMaximized === true) {
createdWindow.maximize();
}
@@ -119,6 +142,10 @@ export function run(): void {
tray.destroy();
tray = null;
}
if (localServerManager) {
void localServerManager.stop();
}
});
app.on("activate", () => {

View File

@@ -1,9 +1,40 @@
import { contextBridge, ipcRenderer } from "electron";
import type { DeepLinkResult, FusionAPI, SystemInfo, UpdateCheckResult } from "./types";
interface ShellConnectionProfile {
id: string;
name: string;
serverUrl: string;
authToken?: string | null;
createdAt: string;
updatedAt: string;
lastUsedAt?: string | null;
}
interface ShellConnectionProfileInput {
id?: string;
name: string;
serverUrl: string;
authToken?: string | null;
}
interface ShellConnectionState {
host: "web" | "mobile-shell" | "desktop-shell";
desktopMode?: "local" | "remote";
activeProfileId: string | null;
profiles: ShellConnectionProfile[];
localServer?: {
status: "idle" | "starting" | "ready" | "error";
port?: number;
error?: string | null;
};
}
export type FusionDesktopAPI = FusionAPI;
contextBridge.exposeInMainWorld("fusionAPI", {
type WindowControlAction = "minimize" | "maximize" | "close" | "isMaximized";
const electronApi = {
// Window control
minimize: (): Promise<void> => ipcRenderer.invoke("window:minimize"),
maximize: (): Promise<boolean> => ipcRenderer.invoke("window:maximize"),
@@ -29,6 +60,22 @@ contextBridge.exposeInMainWorld("fusionAPI", {
return () => ipcRenderer.removeListener("deep-link", handler);
},
windowControl: async (action: WindowControlAction): Promise<boolean | void> => {
switch (action) {
case "minimize":
return ipcRenderer.invoke("window:minimize");
case "maximize":
return ipcRenderer.invoke("window:maximize");
case "close":
return ipcRenderer.invoke("window:close");
case "isMaximized":
return ipcRenderer.invoke("window:isMaximized");
}
},
getPlatform: (): Promise<"darwin" | "win32" | "linux"> => ipcRenderer.invoke("platform:get"),
apiRequest: (method: string, path: string, body?: unknown): Promise<unknown> =>
ipcRenderer.invoke("api-request", { method, path, body }),
// Auto-updater events (main → renderer)
onUpdateAvailable: (callback: (info: { version: string }) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, info: { version: string }) => callback(info);
@@ -40,4 +87,25 @@ contextBridge.exposeInMainWorld("fusionAPI", {
ipcRenderer.on("update-downloaded", handler);
return () => ipcRenderer.removeListener("update-downloaded", handler);
},
});
invoke: (channel: string, payload?: unknown): Promise<unknown> => ipcRenderer.invoke(channel, payload),
};
const fusionShell = {
getState: (): Promise<ShellConnectionState> => ipcRenderer.invoke("shell:getState"),
listProfiles: (): Promise<ShellConnectionProfile[]> => ipcRenderer.invoke("shell:listProfiles"),
saveProfile: (profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile> => ipcRenderer.invoke("shell:saveProfile", profile),
deleteProfile: (profileId: string): Promise<void> => ipcRenderer.invoke("shell:deleteProfile", profileId),
setActiveProfile: (profileId: string | null): Promise<ShellConnectionState> => ipcRenderer.invoke("shell:setActiveProfile", profileId),
setDesktopMode: (mode: "local" | "remote"): Promise<ShellConnectionState> => ipcRenderer.invoke("shell:setDesktopMode", mode),
startQrScan: (): Promise<{ serverUrl: string; authToken?: string | null }> => ipcRenderer.invoke("shell:startQrScan"),
openConnectionManager: (): Promise<void> => ipcRenderer.invoke("shell:openConnectionManager"),
subscribe: (listener: (state: ShellConnectionState) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, state: ShellConnectionState) => listener(state);
ipcRenderer.on("shell:state", handler);
return () => ipcRenderer.removeListener("shell:state", handler);
},
};
contextBridge.exposeInMainWorld("electronAPI", electronApi);
contextBridge.exposeInMainWorld("fusionAPI", electronApi);
contextBridge.exposeInMainWorld("fusionShell", fusionShell);

View File

@@ -0,0 +1,58 @@
import { readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { app } from "electron";
export interface ShellConnectionProfile {
id: string;
name: string;
serverUrl: string;
authToken?: string | null;
createdAt: string;
updatedAt: string;
lastUsedAt?: string | null;
}
export interface DesktopShellSettings {
desktopMode: "local" | "remote";
activeProfileId: string | null;
profiles: ShellConnectionProfile[];
}
const DEFAULT_SETTINGS: DesktopShellSettings = {
desktopMode: "remote",
activeProfileId: null,
profiles: [],
};
function getSettingsPath(): string {
return join(app.getPath("userData"), "shell-connections.json");
}
function normalize(input: unknown): DesktopShellSettings {
if (!input || typeof input !== "object") {
return { ...DEFAULT_SETTINGS };
}
const candidate = input as Partial<DesktopShellSettings>;
return {
desktopMode: candidate.desktopMode === "local" ? "local" : "remote",
activeProfileId: typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null,
profiles: Array.isArray(candidate.profiles) ? candidate.profiles.filter((item) => item && typeof item === "object") as ShellConnectionProfile[] : [],
};
}
export async function readShellSettings(): Promise<DesktopShellSettings> {
try {
const raw = await readFile(getSettingsPath(), "utf-8");
return normalize(JSON.parse(raw));
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export async function writeShellSettings(settings: DesktopShellSettings): Promise<void> {
const path = getSettingsPath();
const temp = `${path}.tmp`;
await writeFile(temp, JSON.stringify(settings, null, 2), "utf-8");
await rename(temp, path);
}

View File

@@ -17,12 +17,15 @@ export interface DeepLinkResult {
raw: string;
}
export type WindowControlAction = "minimize" | "maximize" | "close" | "isMaximized";
export interface FusionAPI {
// Window control
minimize(): Promise<void>;
maximize(): Promise<boolean>;
close(): Promise<void>;
isMaximized(): Promise<boolean>;
windowControl(action: WindowControlAction): Promise<boolean | void>;
// App info
getSystemInfo(): Promise<SystemInfo>;
@@ -42,10 +45,58 @@ export interface FusionAPI {
// Auto-updater events
onUpdateAvailable(callback: (info: { version: string }) => void): () => void;
onUpdateDownloaded(callback: () => void): () => void;
// Generic IPC invoke bridge
invoke(channel: string, payload?: unknown): Promise<unknown>;
apiRequest?(method: string, path: string, body?: unknown): Promise<unknown>;
getPlatform(): Promise<"darwin" | "win32" | "linux">;
}
export interface ShellConnectionProfile {
id: string;
name: string;
serverUrl: string;
authToken?: string | null;
createdAt: string;
updatedAt: string;
lastUsedAt?: string | null;
}
export interface ShellConnectionProfileInput {
id?: string;
name: string;
serverUrl: string;
authToken?: string | null;
}
export interface ShellConnectionState {
host: "web" | "mobile-shell" | "desktop-shell";
desktopMode?: "local" | "remote";
activeProfileId: string | null;
profiles: ShellConnectionProfile[];
localServer?: {
status: "idle" | "starting" | "ready" | "error";
port?: number;
error?: string | null;
};
}
export interface FusionShellApi {
getState(): Promise<ShellConnectionState>;
listProfiles(): Promise<ShellConnectionProfile[]>;
saveProfile(profile: ShellConnectionProfileInput): Promise<ShellConnectionProfile>;
deleteProfile(profileId: string): Promise<void>;
setActiveProfile(profileId: string | null): Promise<ShellConnectionState>;
setDesktopMode(mode: "local" | "remote"): Promise<ShellConnectionState>;
startQrScan(): Promise<{ serverUrl: string; authToken?: string | null }>;
openConnectionManager(): Promise<void>;
subscribe(listener: (state: ShellConnectionState) => void): () => void;
}
declare global {
interface Window {
fusionAPI: FusionAPI;
electronAPI: FusionAPI;
fusionShell?: FusionShellApi;
}
}