feat(FN-1076): integrate desktop IPC bridge and lifecycle modules
- Extract main-process IPC registration into src/ipc.ts for window controls, system info, tray status, updater checks, and native dialogs - Refactor main.ts startup into initializeApp/run and wire menu, tray, deep-link, updater, and window-state restore/quit cleanup - Rework preload bridge to expose typed window.fusionAPI subscriptions and replace preload.d.ts with shared src/types.d.ts global declarations - Add comprehensive tests for IPC handlers, preload contracts, and main-process integration behavior - Document IPC channels, preload API, and lifecycle sequencing in packages/desktop/README.md
This commit is contained in:
@@ -18,6 +18,94 @@ Then, in another terminal, start the desktop app:
|
|||||||
pnpm --filter @fusion/desktop dev
|
pnpm --filter @fusion/desktop dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## IPC Channel Reference
|
||||||
|
|
||||||
|
`src/ipc.ts` registers the renderer ↔ main process bridge used by `window.fusionAPI`.
|
||||||
|
|
||||||
|
### Renderer → Main (`ipcRenderer.invoke`)
|
||||||
|
|
||||||
|
| Channel | Direction | Parameters | Returns |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `window:minimize` | renderer → main | none | `Promise<void>` |
|
||||||
|
| `window:maximize` | renderer → main | none | `Promise<boolean>` (new maximized state) |
|
||||||
|
| `window:close` | renderer → main | none | `Promise<void>` |
|
||||||
|
| `window:isMaximized` | renderer → main | none | `Promise<boolean>` |
|
||||||
|
| `app:getSystemInfo` | renderer → main | none | `Promise<{ platform; arch; electronVersion; nodeVersion; appVersion; }>` |
|
||||||
|
| `app:checkForUpdates` | renderer → main | none | `Promise<{ status: "checking" } \| { status: "error"; error: string }>` |
|
||||||
|
| `tray:updateStatus` | renderer → main | `status: "running" \| "paused" \| "stopped"` | `Promise<void>` |
|
||||||
|
| `native:showExportDialog` | renderer → main | none | `Promise<string \| null>` |
|
||||||
|
| `native:showImportDialog` | renderer → main | none | `Promise<string \| null>` |
|
||||||
|
|
||||||
|
### Main → Renderer Events (`ipcRenderer.on`)
|
||||||
|
|
||||||
|
| Channel | Direction | Payload |
|
||||||
|
|---|---|---|
|
||||||
|
| `deep-link` | main → renderer | `DeepLinkResult` (`{ type, id, raw }`) |
|
||||||
|
| `update-available` | main → renderer | update info object (includes `version`) |
|
||||||
|
| `update-downloaded` | main → renderer | no payload is currently forwarded by preload |
|
||||||
|
|
||||||
|
## Main Process Lifecycle
|
||||||
|
|
||||||
|
`src/main.ts` orchestrates module startup in this order:
|
||||||
|
|
||||||
|
1. `loadWindowState()`
|
||||||
|
2. `createMainWindow(state)`
|
||||||
|
3. `buildAppMenu({ mainWindow, appName: "Fusion" })`
|
||||||
|
4. `setupTray(mainWindow, tray)`
|
||||||
|
5. `registerIpcHandlers(mainWindow, tray)`
|
||||||
|
6. `registerDeepLinkProtocol()`
|
||||||
|
7. `setupDeepLinkHandler(mainWindow)`
|
||||||
|
8. `setupAutoUpdater(mainWindow)`
|
||||||
|
9. `mainWindow.maximize()` when restored state was maximized
|
||||||
|
|
||||||
|
### Window state and close-to-tray behavior
|
||||||
|
|
||||||
|
- Startup restores width/height from persisted state (fallback: `DEFAULT_WINDOW_STATE`).
|
||||||
|
- Position (`x`, `y`) is restored only when both values are present.
|
||||||
|
- On window close:
|
||||||
|
- state is saved via `saveWindowState(mainWindow)`
|
||||||
|
- if app is **not quitting**, close is prevented and the window hides to tray
|
||||||
|
- if app **is quitting**, close proceeds normally
|
||||||
|
|
||||||
|
### Quit cleanup
|
||||||
|
|
||||||
|
- `before-quit` sets `app.isQuitting = true`
|
||||||
|
- Tray instance is destroyed (`tray.destroy()`)
|
||||||
|
- `mainWindow` is nulled on `closed` for clean re-creation on macOS `activate`
|
||||||
|
|
||||||
|
## Preload API (`window.fusionAPI`)
|
||||||
|
|
||||||
|
`src/preload.ts` exposes a safe, context-isolated bridge:
|
||||||
|
|
||||||
|
- Window control: `minimize()`, `maximize()`, `close()`, `isMaximized()`
|
||||||
|
- App/system: `getSystemInfo()`, `checkForUpdates()`
|
||||||
|
- Tray: `updateTrayStatus(status)`
|
||||||
|
- Native dialogs: `showExportDialog()`, `showImportDialog()`
|
||||||
|
- Event subscriptions (return unsubscribe functions):
|
||||||
|
- `onDeepLink(callback)`
|
||||||
|
- `onUpdateAvailable(callback)`
|
||||||
|
- `onUpdateDownloaded(callback)`
|
||||||
|
|
||||||
|
All preload typings are declared in `src/types.d.ts` (`FusionAPI`, `SystemInfo`, `UpdateCheckResult`, `DeepLinkResult`).
|
||||||
|
|
||||||
|
## Module Integration Overview
|
||||||
|
|
||||||
|
```text
|
||||||
|
renderer (window.fusionAPI)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
preload.ts (contextBridge)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ipc.ts handlers ───────────► native.ts (dialogs, updater, window state)
|
||||||
|
│
|
||||||
|
├────────────────────────► tray.ts (status + tray menu wiring)
|
||||||
|
│
|
||||||
|
└────────────────────────► main.ts lifecycle orchestration
|
||||||
|
├─ menu.ts (application menu)
|
||||||
|
└─ deep-link.ts (fusion:// protocol + routing)
|
||||||
|
```
|
||||||
|
|
||||||
## System Tray
|
## System Tray
|
||||||
|
|
||||||
- Left-clicking the tray icon toggles the main window visibility.
|
- Left-clicking the tray icon toggles the main window visibility.
|
||||||
|
|||||||
220
packages/desktop/src/__tests__/ipc.test.ts
Normal file
220
packages/desktop/src/__tests__/ipc.test.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const ipcHandlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||||
|
|
||||||
|
const ipcMain = {
|
||||||
|
handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||||
|
ipcHandlers.set(channel, handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = {
|
||||||
|
getVersion: vi.fn(() => "1.2.3"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTrayStatus = vi.fn();
|
||||||
|
const showExportSettingsDialog = vi.fn();
|
||||||
|
const showImportSettingsDialog = vi.fn();
|
||||||
|
const setupAutoUpdater = vi.fn();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ipcMain,
|
||||||
|
ipcHandlers,
|
||||||
|
app,
|
||||||
|
updateTrayStatus,
|
||||||
|
showExportSettingsDialog,
|
||||||
|
showImportSettingsDialog,
|
||||||
|
setupAutoUpdater,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
ipcMain: mocks.ipcMain,
|
||||||
|
app: mocks.app,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../tray.js", () => ({
|
||||||
|
updateTrayStatus: mocks.updateTrayStatus,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../native.js", () => ({
|
||||||
|
showExportSettingsDialog: mocks.showExportSettingsDialog,
|
||||||
|
showImportSettingsDialog: mocks.showImportSettingsDialog,
|
||||||
|
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createWindowMock() {
|
||||||
|
return {
|
||||||
|
minimize: vi.fn(),
|
||||||
|
maximize: vi.fn(),
|
||||||
|
unmaximize: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
isMaximized: vi.fn(() => false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrayMock() {
|
||||||
|
return {
|
||||||
|
setToolTip: vi.fn(),
|
||||||
|
setContextMenu: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerHandlers() {
|
||||||
|
const { registerIpcHandlers } = await import("../ipc.ts");
|
||||||
|
const window = createWindowMock();
|
||||||
|
const tray = createTrayMock();
|
||||||
|
registerIpcHandlers(window as never, tray as never);
|
||||||
|
return { window, tray };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ipc handlers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
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 () => {
|
||||||
|
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",
|
||||||
|
"tray:updateStatus",
|
||||||
|
"native:showExportDialog",
|
||||||
|
"native:showImportDialog",
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
await registerHandlers();
|
||||||
|
|
||||||
|
const handler = mocks.ipcHandlers.get("app:getSystemInfo");
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("app:checkForUpdates calls setupAutoUpdater and returns checking", async () => {
|
||||||
|
const { window } = await registerHandlers();
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
341
packages/desktop/src/__tests__/main-integration.test.ts
Normal file
341
packages/desktop/src/__tests__/main-integration.test.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const callLog: string[] = [];
|
||||||
|
const appEvents = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
const windowInstances: Array<{
|
||||||
|
instance: ReturnType<typeof createWindowMock>;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}> = [];
|
||||||
|
const trayInstances: Array<ReturnType<typeof createTrayMock>> = [];
|
||||||
|
|
||||||
|
function createWindowMock() {
|
||||||
|
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadURL: vi.fn(() => Promise.resolve()),
|
||||||
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
listeners.set(event, handler);
|
||||||
|
}),
|
||||||
|
hide: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
|
maximize: vi.fn(),
|
||||||
|
isDestroyed: vi.fn(() => false),
|
||||||
|
getBounds: vi.fn(() => ({ x: 50, y: 80, width: 1280, height: 900 })),
|
||||||
|
isMaximized: vi.fn(() => false),
|
||||||
|
getListener: (event: string) => listeners.get(event),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrayMock() {
|
||||||
|
return {
|
||||||
|
destroy: vi.fn(),
|
||||||
|
setImage: vi.fn(),
|
||||||
|
setToolTip: vi.fn(),
|
||||||
|
setContextMenu: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = {
|
||||||
|
whenReady: vi.fn(() => Promise.resolve()),
|
||||||
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
appEvents.set(event, handler);
|
||||||
|
}),
|
||||||
|
quit: vi.fn(),
|
||||||
|
isQuitting: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BrowserWindow = vi.fn((options: Record<string, unknown>) => {
|
||||||
|
callLog.push("createMainWindow");
|
||||||
|
const instance = createWindowMock();
|
||||||
|
windowInstances.push({ instance, options });
|
||||||
|
return instance;
|
||||||
|
});
|
||||||
|
|
||||||
|
const Tray = vi.fn(() => {
|
||||||
|
const tray = createTrayMock();
|
||||||
|
trayInstances.push(tray);
|
||||||
|
return tray;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nativeImage = {
|
||||||
|
createEmpty: vi.fn(() => ({ id: "empty" })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAppMenu = vi.fn(() => {
|
||||||
|
callLog.push("buildAppMenu");
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupTray = vi.fn(() => {
|
||||||
|
callLog.push("setupTray");
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerIpcHandlers = vi.fn(() => {
|
||||||
|
callLog.push("registerIpcHandlers");
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerDeepLinkProtocol = vi.fn(() => {
|
||||||
|
callLog.push("registerDeepLinkProtocol");
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupDeepLinkHandler = vi.fn(() => {
|
||||||
|
callLog.push("setupDeepLinkHandler");
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupAutoUpdater = vi.fn(() => {
|
||||||
|
callLog.push("setupAutoUpdater");
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadWindowState = vi.fn(async () => {
|
||||||
|
callLog.push("loadWindowState");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveWindowState = vi.fn();
|
||||||
|
|
||||||
|
const DEFAULT_WINDOW_STATE = {
|
||||||
|
width: 1280,
|
||||||
|
height: 900,
|
||||||
|
isMaximized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
callLog,
|
||||||
|
appEvents,
|
||||||
|
windowInstances,
|
||||||
|
trayInstances,
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
Tray,
|
||||||
|
nativeImage,
|
||||||
|
buildAppMenu,
|
||||||
|
setupTray,
|
||||||
|
registerIpcHandlers,
|
||||||
|
registerDeepLinkProtocol,
|
||||||
|
setupDeepLinkHandler,
|
||||||
|
setupAutoUpdater,
|
||||||
|
loadWindowState,
|
||||||
|
saveWindowState,
|
||||||
|
DEFAULT_WINDOW_STATE,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => ({
|
||||||
|
loadWindowState: mocks.loadWindowState,
|
||||||
|
saveWindowState: mocks.saveWindowState,
|
||||||
|
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||||
|
DEFAULT_WINDOW_STATE: mocks.DEFAULT_WINDOW_STATE,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function importMainModule() {
|
||||||
|
return import("../main.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPromises() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("main integration", () => {
|
||||||
|
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
mocks.callLog.length = 0;
|
||||||
|
mocks.appEvents.clear();
|
||||||
|
mocks.windowInstances.length = 0;
|
||||||
|
mocks.trayInstances.length = 0;
|
||||||
|
mocks.app.isQuitting = false;
|
||||||
|
mocks.loadWindowState.mockImplementation(async () => {
|
||||||
|
mocks.callLog.push("loadWindowState");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializeApp calls modules in the expected order", async () => {
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
expect(mocks.callLog).toEqual([
|
||||||
|
"loadWindowState",
|
||||||
|
"createMainWindow",
|
||||||
|
"buildAppMenu",
|
||||||
|
"setupTray",
|
||||||
|
"registerIpcHandlers",
|
||||||
|
"registerDeepLinkProtocol",
|
||||||
|
"setupDeepLinkHandler",
|
||||||
|
"setupAutoUpdater",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createMainWindow uses restored window state", async () => {
|
||||||
|
mocks.loadWindowState.mockImplementationOnce(async () => ({
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
width: 1024,
|
||||||
|
height: 768,
|
||||||
|
isMaximized: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
const [{ options }] = mocks.windowInstances;
|
||||||
|
expect(options).toMatchObject({
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
width: 1024,
|
||||||
|
height: 768,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createMainWindow falls back to DEFAULT_WINDOW_STATE when no saved state exists", async () => {
|
||||||
|
mocks.loadWindowState.mockImplementationOnce(async () => null);
|
||||||
|
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
const [{ options }] = mocks.windowInstances;
|
||||||
|
expect(options).toMatchObject({
|
||||||
|
width: mocks.DEFAULT_WINDOW_STATE.width,
|
||||||
|
height: mocks.DEFAULT_WINDOW_STATE.height,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializeApp maximizes the window when restored state is maximized", async () => {
|
||||||
|
mocks.loadWindowState.mockImplementationOnce(async () => ({
|
||||||
|
width: 1280,
|
||||||
|
height: 900,
|
||||||
|
isMaximized: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
const [{ instance }] = mocks.windowInstances;
|
||||||
|
expect(instance.maximize).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildAppMenu is called with mainWindow and Fusion app name", async () => {
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
const [{ instance }] = mocks.windowInstances;
|
||||||
|
expect(mocks.buildAppMenu).toHaveBeenCalledWith({
|
||||||
|
mainWindow: instance,
|
||||||
|
appName: "Fusion",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setupTray is called with mainWindow and tray instance", async () => {
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
const [{ instance }] = mocks.windowInstances;
|
||||||
|
const [trayInstance] = mocks.trayInstances;
|
||||||
|
expect(mocks.setupTray).toHaveBeenCalledWith(instance, trayInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registerIpcHandlers is called with mainWindow and tray", async () => {
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
const [{ instance }] = mocks.windowInstances;
|
||||||
|
const [trayInstance] = mocks.trayInstances;
|
||||||
|
expect(mocks.registerIpcHandlers).toHaveBeenCalledWith(instance, trayInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("window close hides to tray when app is not quitting", async () => {
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
const [{ instance }] = mocks.windowInstances;
|
||||||
|
const closeHandler = instance.getListener("close") as ((event: { preventDefault: () => void }) => void) | undefined;
|
||||||
|
const event = { preventDefault: vi.fn() };
|
||||||
|
|
||||||
|
closeHandler?.(event);
|
||||||
|
|
||||||
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(instance.hide).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("window close saves window state before hiding", async () => {
|
||||||
|
const { initializeApp } = await importMainModule();
|
||||||
|
await initializeApp();
|
||||||
|
|
||||||
|
const [{ instance }] = mocks.windowInstances;
|
||||||
|
const closeHandler = instance.getListener("close") as ((event: { preventDefault: () => void }) => void) | undefined;
|
||||||
|
|
||||||
|
closeHandler?.({ preventDefault: vi.fn() });
|
||||||
|
|
||||||
|
expect(mocks.saveWindowState).toHaveBeenCalledWith(instance);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("before-quit destroys tray and marks app as quitting", async () => {
|
||||||
|
const { run } = await importMainModule();
|
||||||
|
|
||||||
|
run();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const beforeQuitHandler = mocks.appEvents.get("before-quit");
|
||||||
|
beforeQuitHandler?.();
|
||||||
|
|
||||||
|
const [trayInstance] = mocks.trayInstances;
|
||||||
|
expect(mocks.app.isQuitting).toBe(true);
|
||||||
|
expect(trayInstance.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("window-all-closed does not quit on macOS", async () => {
|
||||||
|
if (platformDescriptor) {
|
||||||
|
Object.defineProperty(process, "platform", {
|
||||||
|
configurable: true,
|
||||||
|
value: "darwin",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { run } = await importMainModule();
|
||||||
|
run();
|
||||||
|
|
||||||
|
const windowAllClosedHandler = mocks.appEvents.get("window-all-closed");
|
||||||
|
windowAllClosedHandler?.();
|
||||||
|
|
||||||
|
expect(mocks.app.quit).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
if (platformDescriptor) {
|
||||||
|
Object.defineProperty(process, "platform", platformDescriptor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("importing main module does not auto-start app lifecycle", async () => {
|
||||||
|
await importMainModule();
|
||||||
|
|
||||||
|
expect(mocks.app.whenReady).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -136,23 +136,19 @@ describe("main process", () => {
|
|||||||
expect(mocks.browserWindowInstance.loadURL).toHaveBeenCalledWith(DASHBOARD_URL);
|
expect(mocks.browserWindowInstance.loadURL).toHaveBeenCalledWith(DASHBOARD_URL);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registerIpcHandlers registers app:get-version via handle", async () => {
|
it("exports initializeApp for lifecycle orchestration", async () => {
|
||||||
const { registerIpcHandlers } = await importMainModule();
|
const mainModule = await importMainModule();
|
||||||
|
|
||||||
registerIpcHandlers();
|
expect(typeof mainModule.initializeApp).toBe("function");
|
||||||
|
|
||||||
expect(mocks.ipcMain.handle).toHaveBeenCalledWith(
|
|
||||||
"app:get-version",
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registerIpcHandlers registers app:quit via on", async () => {
|
it("createMainWindow registers close and closed handlers", async () => {
|
||||||
const { registerIpcHandlers } = await importMainModule();
|
const { createMainWindow } = await importMainModule();
|
||||||
|
|
||||||
registerIpcHandlers();
|
createMainWindow();
|
||||||
|
|
||||||
expect(mocks.ipcMain.on).toHaveBeenCalledWith("app:quit", expect.any(Function));
|
expect(mocks.browserWindowInstance.on).toHaveBeenCalledWith("close", expect.any(Function));
|
||||||
|
expect(mocks.browserWindowInstance.on).toHaveBeenCalledWith("closed", expect.any(Function));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("importing main does not auto-start", async () => {
|
it("importing main does not auto-start", async () => {
|
||||||
@@ -161,27 +157,9 @@ describe("main process", () => {
|
|||||||
expect(mocks.app.whenReady).not.toHaveBeenCalled();
|
expect(mocks.app.whenReady).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setupTray configures tray interactions with provided tray instance", async () => {
|
it("exports run for app entrypoint wiring", async () => {
|
||||||
const { setupTray } = await importMainModule();
|
const mainModule = await importMainModule();
|
||||||
|
|
||||||
setupTray(mocks.browserWindowInstance as never, mocks.trayInstance as never);
|
expect(typeof mainModule.run).toBe("function");
|
||||||
|
|
||||||
expect(mocks.nativeImage.createFromPath).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.trayInstance.setImage).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.trayInstance.setToolTip).toHaveBeenCalledWith("Fusion — Running");
|
|
||||||
expect(mocks.Menu.buildFromTemplate).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const closeCall = mocks.browserWindowInstance.on.mock.calls.find(
|
|
||||||
(call) => call[0] === "close",
|
|
||||||
);
|
|
||||||
expect(closeCall).toBeDefined();
|
|
||||||
|
|
||||||
const closeHandler = closeCall?.[1] as (event: { preventDefault: () => void }) => void;
|
|
||||||
const event = { preventDefault: vi.fn() };
|
|
||||||
|
|
||||||
closeHandler(event);
|
|
||||||
|
|
||||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.browserWindowInstance.hide).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => {
|
|||||||
|
|
||||||
const ipcRenderer = {
|
const ipcRenderer = {
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
send: vi.fn(),
|
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
removeListener: vi.fn(),
|
removeListener: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -24,30 +23,22 @@ async function importPreloadModule() {
|
|||||||
await import("../preload.ts");
|
await import("../preload.ts");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExposedFusionDesktopApi() {
|
function getFusionApi() {
|
||||||
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
|
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
|
||||||
(entry) => entry[0] === "fusionDesktop",
|
([name]) => name === "fusionAPI",
|
||||||
) as [string, {
|
) as [string, {
|
||||||
getAppVersion: () => Promise<string>;
|
minimize: () => Promise<void>;
|
||||||
quit: () => void;
|
maximize: () => Promise<boolean>;
|
||||||
onDashboardReady: (callback: () => void) => () => void;
|
close: () => Promise<void>;
|
||||||
}] | undefined;
|
isMaximized: () => Promise<boolean>;
|
||||||
|
getSystemInfo: () => Promise<unknown>;
|
||||||
return call?.[1];
|
checkForUpdates: () => Promise<unknown>;
|
||||||
}
|
updateTrayStatus: (status: string) => Promise<void>;
|
||||||
|
showExportDialog: () => Promise<string | null>;
|
||||||
function getExposedElectronApi() {
|
showImportDialog: () => Promise<string | null>;
|
||||||
const call = mocks.contextBridge.exposeInMainWorld.mock.calls.find(
|
onDeepLink: (callback: (result: unknown) => void) => () => void;
|
||||||
(entry) => entry[0] === "electronAPI",
|
onUpdateAvailable: (callback: (info: { version: string }) => void) => () => void;
|
||||||
) as [string, {
|
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||||
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;
|
}] | undefined;
|
||||||
|
|
||||||
return call?.[1];
|
return call?.[1];
|
||||||
@@ -59,96 +50,144 @@ describe("preload", () => {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes fusionDesktop and electronAPI", async () => {
|
it("contextBridge.exposeInMainWorld is called with fusionAPI", async () => {
|
||||||
await importPreloadModule();
|
await importPreloadModule();
|
||||||
|
|
||||||
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
|
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
|
||||||
"fusionDesktop",
|
"fusionAPI",
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
|
|
||||||
"electronAPI",
|
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fusionDesktop.getAppVersion calls ipcRenderer.invoke", async () => {
|
it("minimize invokes window:minimize", async () => {
|
||||||
mocks.ipcRenderer.invoke.mockResolvedValue("0.1.0");
|
|
||||||
await importPreloadModule();
|
await importPreloadModule();
|
||||||
|
|
||||||
const api = getExposedFusionDesktopApi();
|
const api = getFusionApi();
|
||||||
const version = await api?.getAppVersion();
|
await api?.minimize();
|
||||||
|
|
||||||
expect(version).toBe("0.1.0");
|
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:minimize");
|
||||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("app:get-version");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fusionDesktop.quit calls ipcRenderer.send", async () => {
|
it("maximize invokes window:maximize", async () => {
|
||||||
await importPreloadModule();
|
await importPreloadModule();
|
||||||
|
|
||||||
const api = getExposedFusionDesktopApi();
|
const api = getFusionApi();
|
||||||
api?.quit();
|
await api?.maximize();
|
||||||
|
|
||||||
expect(mocks.ipcRenderer.send).toHaveBeenCalledWith("app:quit");
|
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:maximize");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fusionDesktop.onDashboardReady returns unsubscribe function", async () => {
|
it("close invokes window:close", async () => {
|
||||||
await importPreloadModule();
|
await importPreloadModule();
|
||||||
|
|
||||||
const api = getExposedFusionDesktopApi();
|
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("updateTrayStatus invokes tray:updateStatus with status argument", async () => {
|
||||||
|
await importPreloadModule();
|
||||||
|
|
||||||
|
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 callback = vi.fn();
|
||||||
const unsubscribe = api?.onDashboardReady(callback);
|
const unsubscribe = api?.onDeepLink(callback);
|
||||||
|
|
||||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith(
|
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("deep-link", expect.any(Function));
|
||||||
"dashboard:ready",
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
expect(typeof unsubscribe).toBe("function");
|
|
||||||
|
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
|
|
||||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||||
"dashboard:ready",
|
"deep-link",
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("electronAPI methods invoke expected IPC channels", async () => {
|
it("onUpdateAvailable subscribes to update-available and returns unsubscribe", async () => {
|
||||||
await importPreloadModule();
|
await importPreloadModule();
|
||||||
|
|
||||||
const api = getExposedElectronApi();
|
const api = getFusionApi();
|
||||||
await api?.invoke("api-request", { method: "GET", path: "/tasks" });
|
const callback = vi.fn();
|
||||||
await api?.apiRequest("POST", "/tasks", { title: "Task" });
|
const unsubscribe = api?.onUpdateAvailable(callback);
|
||||||
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.on).toHaveBeenCalledWith("update-available", expect.any(Function));
|
||||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("api-request", { method: "POST", path: "/tasks", body: { title: "Task" } });
|
|
||||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("server:get-port");
|
unsubscribe?.();
|
||||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("window:control", "maximize");
|
|
||||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("update:install");
|
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("system:get-platform");
|
"update-available",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("electronAPI event subscriptions provide unsubscribe functions", async () => {
|
it("onUpdateDownloaded subscribes to update-downloaded and returns unsubscribe", async () => {
|
||||||
await importPreloadModule();
|
await importPreloadModule();
|
||||||
|
|
||||||
const api = getExposedElectronApi();
|
const api = getFusionApi();
|
||||||
const onUpdate = vi.fn();
|
const callback = vi.fn();
|
||||||
const onDeepLink = vi.fn();
|
const unsubscribe = api?.onUpdateDownloaded(callback);
|
||||||
|
|
||||||
const unsubscribeUpdate = api?.onUpdateAvailable(onUpdate);
|
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update-downloaded", expect.any(Function));
|
||||||
const unsubscribeDeepLink = api?.onDeepLink(onDeepLink);
|
|
||||||
|
|
||||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("update:available", expect.any(Function));
|
unsubscribe?.();
|
||||||
expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("deep-link", expect.any(Function));
|
|
||||||
|
|
||||||
unsubscribeUpdate?.();
|
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith(
|
||||||
unsubscribeDeepLink?.();
|
"update-downloaded",
|
||||||
|
expect.any(Function),
|
||||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("update:available", expect.any(Function));
|
);
|
||||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("deep-link", expect.any(Function));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
export {
|
export { DASHBOARD_URL, createMainWindow, initializeApp, run } from "./main.js";
|
||||||
createMainWindow,
|
export { registerIpcHandlers } from "./ipc.js";
|
||||||
setupTray,
|
|
||||||
updateTrayStatus,
|
|
||||||
registerIpcHandlers,
|
|
||||||
DASHBOARD_URL,
|
|
||||||
run,
|
|
||||||
} from "./main.js";
|
|
||||||
|
|
||||||
export { createTrayIcon, buildTrayContextMenu, getTrayTooltip } from "./tray.js";
|
export * from "./tray.js";
|
||||||
export { buildMenuTemplate, buildAppMenu } from "./menu.js";
|
export * from "./menu.js";
|
||||||
|
export * from "./native.js";
|
||||||
|
export * from "./deep-link.js";
|
||||||
|
|
||||||
export type { EngineStatus, TrayMenuOptions } from "./tray.js";
|
export type { FusionAPI, SystemInfo, UpdateCheckResult } from "./types";
|
||||||
export type { AppMenuOptions } from "./menu.js";
|
|
||||||
export type { FusionDesktopAPI } from "./preload.js";
|
|
||||||
|
|||||||
53
packages/desktop/src/ipc.ts
Normal file
53
packages/desktop/src/ipc.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
|
||||||
|
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog } from "./native.js";
|
||||||
|
import { type EngineStatus, updateTrayStatus } from "./tray.js";
|
||||||
|
|
||||||
|
export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray): 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:isMaximized", () => mainWindow.isMaximized());
|
||||||
|
|
||||||
|
ipcMain.handle("app:getSystemInfo", () => ({
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
electronVersion: process.versions.electron,
|
||||||
|
nodeVersion: process.versions.node,
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ipcMain.handle("app:checkForUpdates", () => {
|
||||||
|
try {
|
||||||
|
setupAutoUpdater(mainWindow);
|
||||||
|
return { status: "checking" as const };
|
||||||
|
} catch (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("native:showExportDialog", () => showExportSettingsDialog(mainWindow));
|
||||||
|
ipcMain.handle("native:showImportDialog", () => showImportSettingsDialog(mainWindow));
|
||||||
|
}
|
||||||
@@ -1,49 +1,38 @@
|
|||||||
|
import { app, BrowserWindow, nativeImage, Tray } from "electron";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { app, BrowserWindow, Tray, ipcMain, nativeImage } from "electron";
|
import { setupDeepLinkHandler, registerDeepLinkProtocol } from "./deep-link.js";
|
||||||
|
import { registerIpcHandlers } from "./ipc.js";
|
||||||
import { buildAppMenu } from "./menu.js";
|
import { buildAppMenu } from "./menu.js";
|
||||||
import { setupTray, updateTrayStatus } from "./tray.js";
|
import {
|
||||||
|
DEFAULT_WINDOW_STATE,
|
||||||
|
loadWindowState,
|
||||||
|
saveWindowState,
|
||||||
|
setupAutoUpdater,
|
||||||
|
type WindowState,
|
||||||
|
} from "./native.js";
|
||||||
|
import { setupTray } from "./tray.js";
|
||||||
|
|
||||||
|
interface AppWithQuitFlag {
|
||||||
|
isQuitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
let tray: Tray | null = null;
|
||||||
|
|
||||||
export const DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL || "http://localhost:4040";
|
export const DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL || "http://localhost:4040";
|
||||||
|
|
||||||
interface ApiRequestPayload {
|
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
|
||||||
method?: string;
|
return app as Electron.App & AppWithQuitFlag;
|
||||||
path: string;
|
|
||||||
body?: unknown;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
port?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDashboardBaseUrl(portOverride?: number): URL {
|
export function createMainWindow(state?: WindowState): BrowserWindow {
|
||||||
const dashboardUrl = new URL(DASHBOARD_URL);
|
const hasValidPosition = typeof state?.x === "number" && typeof state?.y === "number";
|
||||||
if (typeof portOverride === "number" && Number.isFinite(portOverride)) {
|
|
||||||
dashboardUrl.port = String(portOverride);
|
|
||||||
}
|
|
||||||
return dashboardUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDashboardPort(): number {
|
const window = new BrowserWindow({
|
||||||
const dashboardUrl = createDashboardBaseUrl();
|
width: state?.width ?? DEFAULT_WINDOW_STATE.width,
|
||||||
const port = Number.parseInt(dashboardUrl.port || "", 10);
|
height: state?.height ?? DEFAULT_WINDOW_STATE.height,
|
||||||
if (!Number.isFinite(port) || port <= 0) {
|
...(hasValidPosition ? { x: state.x, y: state.y } : {}),
|
||||||
return dashboardUrl.protocol === "https:" ? 443 : 80;
|
|
||||||
}
|
|
||||||
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildApiUrl(path: string, portOverride?: number): string {
|
|
||||||
const normalizedPath = path.startsWith("/api")
|
|
||||||
? path
|
|
||||||
: `/api${path.startsWith("/") ? path : `/${path}`}`;
|
|
||||||
|
|
||||||
return new URL(normalizedPath, createDashboardBaseUrl(portOverride)).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMainWindow(): BrowserWindow {
|
|
||||||
const mainWindow = new BrowserWindow({
|
|
||||||
width: 1280,
|
|
||||||
height: 900,
|
|
||||||
title: "Fusion",
|
title: "Fusion",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(import.meta.dirname, "preload.ts"),
|
preload: join(import.meta.dirname, "preload.ts"),
|
||||||
@@ -52,152 +41,77 @@ export function createMainWindow(): BrowserWindow {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
void mainWindow.loadURL(DASHBOARD_URL);
|
void window.loadURL(DASHBOARD_URL);
|
||||||
return mainWindow;
|
|
||||||
|
window.on("close", (event) => {
|
||||||
|
saveWindowState(window);
|
||||||
|
|
||||||
|
if (getAppWithQuitFlag().isQuitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
window.hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.on("closed", () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow = window;
|
||||||
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export async function initializeApp(): Promise<void> {
|
||||||
ipcMain.handle("app:get-version", () => app.getVersion());
|
const state = await loadWindowState();
|
||||||
ipcMain.on("app:quit", () => app.quit());
|
const createdWindow = createMainWindow(state ?? undefined);
|
||||||
|
|
||||||
ipcMain.handle("server:get-port", () => getDashboardPort());
|
buildAppMenu({
|
||||||
|
mainWindow: createdWindow,
|
||||||
ipcMain.handle("api-request", async (_event, payload: ApiRequestPayload) => {
|
appName: "Fusion",
|
||||||
const method = (payload.method ?? "GET").toUpperCase();
|
|
||||||
const headers: Record<string, string> = { ...(payload.headers ?? {}) };
|
|
||||||
|
|
||||||
let requestBody: string | undefined;
|
|
||||||
if (payload.body !== undefined && method !== "GET" && method !== "HEAD") {
|
|
||||||
if (typeof payload.body === "string") {
|
|
||||||
requestBody = payload.body;
|
|
||||||
} else {
|
|
||||||
requestBody = JSON.stringify(payload.body);
|
|
||||||
if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(buildApiUrl(payload.path, payload.port), {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: requestBody,
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseText = await response.text();
|
|
||||||
const responseContentType = response.headers.get("content-type") ?? "";
|
|
||||||
|
|
||||||
let responseData: unknown = responseText;
|
|
||||||
if (responseContentType.includes("application/json")) {
|
|
||||||
try {
|
|
||||||
responseData = responseText ? JSON.parse(responseText) : null;
|
|
||||||
} catch {
|
|
||||||
responseData = responseText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseError = response.ok
|
|
||||||
? undefined
|
|
||||||
: (typeof responseData === "object" && responseData && "error" in responseData
|
|
||||||
? String((responseData as { error?: string }).error ?? "Request failed")
|
|
||||||
: responseText || `Request failed (${response.status})`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
headers: Object.fromEntries(response.headers.entries()),
|
|
||||||
data: responseData,
|
|
||||||
error: responseError,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
status: 500,
|
|
||||||
statusText: "Internal Error",
|
|
||||||
headers: {},
|
|
||||||
data: null,
|
|
||||||
error: error instanceof Error ? error.message : "Failed to process API request",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("window:control", (event, action: "minimize" | "maximize" | "close" | "isMaximized") => {
|
tray = new Tray(nativeImage.createEmpty());
|
||||||
const targetWindow = BrowserWindow.fromWebContents(event.sender);
|
setupTray(createdWindow, tray);
|
||||||
if (!targetWindow) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
registerIpcHandlers(createdWindow, tray);
|
||||||
case "minimize":
|
registerDeepLinkProtocol();
|
||||||
targetWindow.minimize();
|
setupDeepLinkHandler(createdWindow);
|
||||||
return false;
|
setupAutoUpdater(createdWindow);
|
||||||
case "maximize": {
|
|
||||||
const willMaximize = !targetWindow.isMaximized();
|
|
||||||
if (willMaximize) {
|
|
||||||
targetWindow.maximize();
|
|
||||||
} else {
|
|
||||||
targetWindow.unmaximize();
|
|
||||||
}
|
|
||||||
return willMaximize;
|
|
||||||
}
|
|
||||||
case "close":
|
|
||||||
targetWindow.close();
|
|
||||||
return false;
|
|
||||||
case "isMaximized":
|
|
||||||
return targetWindow.isMaximized();
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle("update:install", async () => {
|
if (state?.isMaximized === true) {
|
||||||
// Auto-update wiring is implemented by FN-1071. Keep this as a safe no-op
|
createdWindow.maximize();
|
||||||
// so renderer hooks can call installUpdate() without exploding.
|
}
|
||||||
return;
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle("system:get-platform", () => process.platform);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function run(): void {
|
export function run(): void {
|
||||||
let tray: Tray | undefined;
|
const appWithQuitFlag = getAppWithQuitFlag();
|
||||||
|
appWithQuitFlag.isQuitting = false;
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
void app.whenReady().then(() => initializeApp());
|
||||||
const mainWindow = createMainWindow();
|
|
||||||
const trayInstance = tray ?? new Tray(nativeImage.createEmpty());
|
|
||||||
tray = setupTray(mainWindow, trayInstance);
|
|
||||||
|
|
||||||
buildAppMenu({
|
|
||||||
mainWindow,
|
|
||||||
appName: "Fusion",
|
|
||||||
});
|
|
||||||
|
|
||||||
registerIpcHandlers();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
// Keep app alive in tray on non-macOS platforms.
|
app.quit();
|
||||||
return;
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("before-quit", () => {
|
||||||
|
appWithQuitFlag.isQuitting = true;
|
||||||
|
if (tray) {
|
||||||
|
tray.destroy();
|
||||||
|
tray = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (mainWindow === null) {
|
||||||
const mainWindow = createMainWindow();
|
const window = createMainWindow();
|
||||||
const trayInstance = tray ?? new Tray(nativeImage.createEmpty());
|
window.show();
|
||||||
tray = setupTray(mainWindow, trayInstance);
|
|
||||||
|
|
||||||
buildAppMenu({
|
|
||||||
mainWindow,
|
|
||||||
appName: "Fusion",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { setupTray, updateTrayStatus };
|
|
||||||
|
|
||||||
const modulePath = fileURLToPath(import.meta.url);
|
const modulePath = fileURLToPath(import.meta.url);
|
||||||
if (process.argv[1] && resolve(process.argv[1]) === modulePath) {
|
if (process.argv[1] && resolve(process.argv[1]) === modulePath) {
|
||||||
run();
|
run();
|
||||||
|
|||||||
14
packages/desktop/src/preload.d.ts
vendored
14
packages/desktop/src/preload.d.ts
vendored
@@ -1,14 +0,0 @@
|
|||||||
import type { ElectronAPI } from "./renderer/types";
|
|
||||||
|
|
||||||
export interface FusionDesktopAPI {
|
|
||||||
getAppVersion(): Promise<string>;
|
|
||||||
quit(): void;
|
|
||||||
onDashboardReady(callback: () => void): () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
fusionDesktop: FusionDesktopAPI;
|
|
||||||
electronAPI?: ElectronAPI;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +1,42 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
import type { ElectronAPI, ElectronApiResponsePayload, WindowControlAction } from "./renderer/types";
|
import type { DeepLinkResult, FusionAPI, SystemInfo, UpdateCheckResult } from "./types";
|
||||||
|
|
||||||
export interface FusionDesktopAPI {
|
export type FusionDesktopAPI = FusionAPI;
|
||||||
getAppVersion(): Promise<string>;
|
|
||||||
quit(): void;
|
|
||||||
onDashboardReady(callback: () => void): () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fusionDesktop: FusionDesktopAPI = {
|
contextBridge.exposeInMainWorld("fusionAPI", {
|
||||||
getAppVersion(): Promise<string> {
|
// Window control
|
||||||
return ipcRenderer.invoke("app:get-version");
|
minimize: (): Promise<void> => ipcRenderer.invoke("window:minimize"),
|
||||||
},
|
maximize: (): Promise<boolean> => ipcRenderer.invoke("window:maximize"),
|
||||||
quit(): void {
|
close: (): Promise<void> => ipcRenderer.invoke("window:close"),
|
||||||
ipcRenderer.send("app:quit");
|
isMaximized: (): Promise<boolean> => ipcRenderer.invoke("window:isMaximized"),
|
||||||
},
|
|
||||||
onDashboardReady(callback: () => void): () => void {
|
|
||||||
const listener = () => callback();
|
|
||||||
ipcRenderer.on("dashboard:ready", listener);
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener("dashboard:ready", listener);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const electronAPI: ElectronAPI = {
|
// App info
|
||||||
invoke(channel: string, payload?: unknown): Promise<unknown> {
|
getSystemInfo: (): Promise<SystemInfo> => ipcRenderer.invoke("app:getSystemInfo"),
|
||||||
return ipcRenderer.invoke(channel, payload);
|
checkForUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke("app:checkForUpdates"),
|
||||||
},
|
|
||||||
apiRequest(method: string, path: string, body?: unknown): Promise<ElectronApiResponsePayload> {
|
|
||||||
return ipcRenderer.invoke("api-request", { method, path, body });
|
|
||||||
},
|
|
||||||
getServerPort(): Promise<number> {
|
|
||||||
return ipcRenderer.invoke("server:get-port");
|
|
||||||
},
|
|
||||||
windowControl(action: WindowControlAction): Promise<boolean | void> {
|
|
||||||
return ipcRenderer.invoke("window:control", action);
|
|
||||||
},
|
|
||||||
onUpdateAvailable(callback: (info: Record<string, unknown>) => void): () => void {
|
|
||||||
const listener = (_event: unknown, info: Record<string, unknown>) => {
|
|
||||||
callback(info);
|
|
||||||
};
|
|
||||||
ipcRenderer.on("update:available", listener);
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener("update:available", listener);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
installUpdate(): Promise<void> {
|
|
||||||
return ipcRenderer.invoke("update:install");
|
|
||||||
},
|
|
||||||
onDeepLink(callback: (url: string) => void): () => void {
|
|
||||||
const listener = (_event: unknown, url: string) => {
|
|
||||||
callback(url);
|
|
||||||
};
|
|
||||||
ipcRenderer.on("deep-link", listener);
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener("deep-link", listener);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getPlatform() {
|
|
||||||
return ipcRenderer.invoke("system:get-platform");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("fusionDesktop", fusionDesktop);
|
// Tray status
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI);
|
updateTrayStatus: (status: string): Promise<void> => ipcRenderer.invoke("tray:updateStatus", status),
|
||||||
|
|
||||||
|
// Native dialogs
|
||||||
|
showExportDialog: (): Promise<string | null> => ipcRenderer.invoke("native:showExportDialog"),
|
||||||
|
showImportDialog: (): Promise<string | null> => ipcRenderer.invoke("native:showImportDialog"),
|
||||||
|
|
||||||
|
// Deep link events (main → renderer)
|
||||||
|
onDeepLink: (callback: (result: DeepLinkResult) => void): (() => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, result: DeepLinkResult) => callback(result);
|
||||||
|
ipcRenderer.on("deep-link", handler);
|
||||||
|
return () => ipcRenderer.removeListener("deep-link", handler);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auto-updater events (main → renderer)
|
||||||
|
onUpdateAvailable: (callback: (info: { version: string }) => void): (() => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, info: { version: string }) => callback(info);
|
||||||
|
ipcRenderer.on("update-available", handler);
|
||||||
|
return () => ipcRenderer.removeListener("update-available", handler);
|
||||||
|
},
|
||||||
|
onUpdateDownloaded: (callback: () => void): (() => void) => {
|
||||||
|
const handler = () => callback();
|
||||||
|
ipcRenderer.on("update-downloaded", handler);
|
||||||
|
return () => ipcRenderer.removeListener("update-downloaded", handler);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
50
packages/desktop/src/types.d.ts
vendored
Normal file
50
packages/desktop/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export interface SystemInfo {
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
electronVersion: string;
|
||||||
|
nodeVersion: string;
|
||||||
|
appVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCheckResult {
|
||||||
|
status: "checking" | "error";
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeepLinkResult {
|
||||||
|
type: "task" | "project" | "unknown";
|
||||||
|
id: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FusionAPI {
|
||||||
|
// Window control
|
||||||
|
minimize(): Promise<void>;
|
||||||
|
maximize(): Promise<boolean>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
isMaximized(): Promise<boolean>;
|
||||||
|
|
||||||
|
// App info
|
||||||
|
getSystemInfo(): Promise<SystemInfo>;
|
||||||
|
checkForUpdates(): Promise<UpdateCheckResult>;
|
||||||
|
|
||||||
|
// Tray status
|
||||||
|
updateTrayStatus(status: string): Promise<void>;
|
||||||
|
|
||||||
|
// Native dialogs
|
||||||
|
showExportDialog(): Promise<string | null>;
|
||||||
|
showImportDialog(): Promise<string | null>;
|
||||||
|
|
||||||
|
// Deep link events
|
||||||
|
onDeepLink(callback: (result: DeepLinkResult) => void): () => void;
|
||||||
|
|
||||||
|
// Auto-updater events
|
||||||
|
onUpdateAvailable(callback: (info: { version: string }) => void): () => void;
|
||||||
|
onUpdateDownloaded(callback: () => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
fusionAPI: FusionAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user