feat(FN-1072): add Electron desktop renderer integration

- Add Electron main-process IPC handlers and preload bridges for API proxying, window controls, update install, and platform lookup
- Introduce a desktop renderer entrypoint with DesktopWrapper and a custom frameless TitleBar component
- Add Electron-aware renderer utilities including API transport selection and hooks for runtime detection, auto-update events, and deep links
- Update dashboard header behavior for Electron mode and expand desktop tests across preload, transport, hooks, and title bar flows
- Document the desktop renderer architecture and update desktop package config/dependencies
This commit is contained in:
gsxdsm
2026-04-08 00:16:25 -07:00
parent 8c5b5e128c
commit a4a99f31b0
27 changed files with 1325 additions and 28 deletions

View File

@@ -24,14 +24,31 @@ async function importPreloadModule() {
await import("../preload.ts");
}
function getExposedApi() {
const call = mocks.contextBridge.exposeInMainWorld.mock.calls[0] as
| [string, {
getAppVersion: () => Promise<string>;
quit: () => void;
onDashboardReady: (callback: () => void) => () => void;
}]
| undefined;
function getExposedFusionDesktopApi() {
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
(entry) => entry[0] === "fusionDesktop",
) as [string, {
getAppVersion: () => Promise<string>;
quit: () => void;
onDashboardReady: (callback: () => void) => () => void;
}] | undefined;
return call?.[1];
}
function getExposedElectronApi() {
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
(entry) => entry[0] === "electronAPI",
) as [string, {
invoke: (channel: string, payload?: unknown) => Promise<unknown>;
apiRequest: (method: string, path: string, body?: unknown) => Promise<unknown>;
getServerPort: () => Promise<number>;
windowControl: (action: string) => Promise<boolean | void>;
onUpdateAvailable: (callback: (info: Record<string, unknown>) => void) => () => void;
installUpdate: () => Promise<void>;
onDeepLink: (callback: (url: string) => void) => () => void;
getPlatform: () => Promise<string>;
}] | undefined;
return call?.[1];
}
@@ -42,39 +59,43 @@ describe("preload", () => {
vi.resetModules();
});
it("contextBridge.exposeInMainWorld called with fusionDesktop", async () => {
it("exposes fusionDesktop and electronAPI", async () => {
await importPreloadModule();
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
"fusionDesktop",
expect.any(Object),
);
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
"electronAPI",
expect.any(Object),
);
});
it("getAppVersion calls ipcRenderer.invoke", async () => {
it("fusionDesktop.getAppVersion calls ipcRenderer.invoke", async () => {
mocks.ipcRenderer.invoke.mockResolvedValue("0.1.0");
await importPreloadModule();
const api = getExposedApi();
const api = getExposedFusionDesktopApi();
const version = await api?.getAppVersion();
expect(version).toBe("0.1.0");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:get-version");
});
it("quit calls ipcRenderer.send", async () => {
it("fusionDesktop.quit calls ipcRenderer.send", async () => {
await importPreloadModule();
const api = getExposedApi();
const api = getExposedFusionDesktopApi();
api?.quit();
expect(mocks.ipcRenderer.send).toHaveBeenCalledWith("app:quit");
});
it("onDashboardReady returns unsubscribe function", async () => {
it("fusionDesktop.onDashboardReady returns unsubscribe function", async () => {
await importPreloadModule();
const api = getExposedApi();
const api = getExposedFusionDesktopApi();
const callback = vi.fn();
const unsubscribe = api?.onDashboardReady(callback);
@@ -91,4 +112,43 @@ describe("preload", () => {
expect.any(Function),
);
});
it("electronAPI methods invoke expected IPC channels", async () => {
await importPreloadModule();
const api = getExposedElectronApi();
await api?.invoke("api-request", { method: "GET", path: "/tasks" });
await api?.apiRequest("POST", "/tasks", { title: "Task" });
await api?.getServerPort();
await api?.windowControl("maximize");
await api?.installUpdate();
await api?.getPlatform();
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("api-request", { method: "GET", path: "/tasks" });
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("api-request", { method: "POST", path: "/tasks", body: { title: "Task" } });
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("server:get-port");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:control", "maximize");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("update:install");
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("system:get-platform");
});
it("electronAPI event subscriptions provide unsubscribe functions", async () => {
await importPreloadModule();
const api = getExposedElectronApi();
const onUpdate = vi.fn();
const onDeepLink = vi.fn();
const unsubscribeUpdate = api?.onUpdateAvailable(onUpdate);
const unsubscribeDeepLink = api?.onDeepLink(onDeepLink);
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update:available", expect.any(Function));
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("deep-link", expect.any(Function));
unsubscribeUpdate?.();
unsubscribeDeepLink?.();
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("update:available", expect.any(Function));
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("deep-link", expect.any(Function));
});
});