feat(FN-1073): add desktop CLI workflow and packaging support

- Add new `fn desktop` CLI command with argument handling and comprehensive command/bin tests
- Implement desktop build and hot-reload dev scripts and wire package scripts/dependencies for Electron workflows
- Add electron-builder configuration and desktop main-process/integration test coverage to stabilize packaging behavior
- Document desktop development and usage in README files and include a changeset for the published CLI package
This commit is contained in:
gsxdsm
2026-04-08 01:40:05 -07:00
parent 025b60164d
commit be19cb974c
19 changed files with 1006 additions and 50 deletions

View File

@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const app = {
whenReady: vi.fn(() => Promise.resolve()),
on: vi.fn(),
quit: vi.fn(),
};
const browserWindow = {
loadURL: vi.fn(),
on: vi.fn(),
hide: vi.fn(),
show: vi.fn(),
maximize: vi.fn(),
};
return {
app,
BrowserWindow: vi.fn(() => browserWindow),
Tray: vi.fn(() => ({
destroy: vi.fn(),
setImage: vi.fn(),
setContextMenu: vi.fn(),
setToolTip: vi.fn(),
on: vi.fn(),
})),
nativeImage: {
createEmpty: vi.fn(() => ({ id: "empty-image" })),
},
browserWindow,
buildAppMenu: vi.fn(),
setupTray: vi.fn(),
registerIpcHandlers: vi.fn(),
registerDeepLinkProtocol: vi.fn(),
setupDeepLinkHandler: vi.fn(),
loadWindowState: vi.fn(async () => null),
saveWindowState: vi.fn(),
setupAutoUpdater: vi.fn(),
};
});
vi.mock("electron", () => ({
app: mocks.app,
BrowserWindow: mocks.BrowserWindow,
Tray: mocks.Tray,
nativeImage: mocks.nativeImage,
}));
vi.mock("../menu.js", () => ({
buildAppMenu: mocks.buildAppMenu,
}));
vi.mock("../tray.js", () => ({
setupTray: mocks.setupTray,
}));
vi.mock("../ipc.js", () => ({
registerIpcHandlers: mocks.registerIpcHandlers,
}));
vi.mock("../deep-link.js", () => ({
registerDeepLinkProtocol: mocks.registerDeepLinkProtocol,
setupDeepLinkHandler: mocks.setupDeepLinkHandler,
}));
vi.mock("../native.js", () => ({
DEFAULT_WINDOW_STATE: {
width: 1280,
height: 900,
isMaximized: false,
},
loadWindowState: mocks.loadWindowState,
saveWindowState: mocks.saveWindowState,
setupAutoUpdater: mocks.setupAutoUpdater,
}));
describe("main module integration", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it("imports main module exports with a mocked electron runtime", async () => {
const mainModule = await import("../main.ts");
expect(mainModule.run).toBeTypeOf("function");
expect(mainModule.initializeApp).toBeTypeOf("function");
expect(mainModule.createMainWindow).toBeTypeOf("function");
});
it("initializes app lifecycle wiring without throwing", async () => {
const { initializeApp } = await import("../main.ts");
await expect(initializeApp()).resolves.toBeUndefined();
expect(mocks.BrowserWindow).toHaveBeenCalledTimes(1);
expect(mocks.registerIpcHandlers).toHaveBeenCalledTimes(1);
expect(mocks.setupTray).toHaveBeenCalledTimes(1);
});
});

View File

@@ -91,12 +91,13 @@ describe("main process", () => {
}
});
it("DASHBOARD_URL defaults to localhost:4040", async () => {
it("DASHBOARD_URL defaults to local file URL in production mode", async () => {
delete process.env.FUSION_DASHBOARD_URL;
const { DASHBOARD_URL } = await importMainModule();
expect(DASHBOARD_URL).toBe("http://localhost:4040");
expect(DASHBOARD_URL.startsWith("file://")).toBe(true);
expect(DASHBOARD_URL).toContain("/client/index.html");
});
it("DASHBOARD_URL uses env override", async () => {
@@ -125,7 +126,7 @@ describe("main process", () => {
expect(options.webPreferences.contextIsolation).toBe(true);
expect(options.webPreferences.nodeIntegration).toBe(false);
expect(options.webPreferences.preload).toContain("preload.ts");
expect(options.webPreferences.preload).toContain("preload.js");
});
it("createMainWindow loads the dashboard URL", async () => {

View File

@@ -1,6 +1,6 @@
import { app, BrowserWindow, nativeImage, Tray } from "electron";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { setupDeepLinkHandler, registerDeepLinkProtocol } from "./deep-link.js";
import { registerIpcHandlers } from "./ipc.js";
import { buildAppMenu } from "./menu.js";
@@ -17,11 +17,33 @@ interface AppWithQuitFlag {
isQuitting?: boolean;
}
const DEFAULT_DEV_DASHBOARD_URL = "http://localhost:5173";
function isDevelopmentMode(): boolean {
return process.env.NODE_ENV === "development" || process.argv.includes("--dev");
}
const PRODUCTION_DASHBOARD_URL = pathToFileURL(
join(import.meta.dirname, "client", "index.html"),
).toString();
export const IS_DEVELOPMENT = isDevelopmentMode();
export const DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL ?? (
IS_DEVELOPMENT ? DEFAULT_DEV_DASHBOARD_URL : PRODUCTION_DASHBOARD_URL
);
function enableSourceMaps(): void {
const processWithSourceMaps = process as NodeJS.Process & {
setSourceMapsEnabled?: (enabled: boolean) => void;
};
processWithSourceMaps.setSourceMapsEnabled?.(true);
}
enableSourceMaps();
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
export const DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL || "http://localhost:4040";
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
return app as Electron.App & AppWithQuitFlag;
}
@@ -35,7 +57,7 @@ export function createMainWindow(state?: WindowState): BrowserWindow {
...(hasValidPosition ? { x: state.x, y: state.y } : {}),
title: "Fusion",
webPreferences: {
preload: join(import.meta.dirname, "preload.ts"),
preload: join(import.meta.dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},