feat(FN-5022): merge fusion/fn-5022
This commit is contained in:
@@ -90,7 +90,7 @@ Desktop boots through a shell-owned mode chooser before mounting the dashboard a
|
||||
| `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 }>` |
|
||||
| `app:checkForUpdates` | renderer → main | none | `Promise<{ status: "checking" } \| { status: "unavailable"; reason: string } \| { status: "error"; error: string }>` |
|
||||
| `app:getServerPort` | renderer → main | none | `Promise<number \| undefined>` (external CLI port when present; otherwise embedded local runtime port when running) |
|
||||
| `desktopRuntime:getStatus` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
|
||||
| `desktopRuntime:startLocal` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
|
||||
@@ -108,6 +108,8 @@ Desktop boots through a shell-owned mode chooser before mounting the dashboard a
|
||||
| `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 |
|
||||
| `update-not-available` | main → renderer | update info object (typically includes current `version`) |
|
||||
| `update-error` | main → renderer | `{ message: string }` |
|
||||
|
||||
## Local Bundled Runtime Lifecycle
|
||||
|
||||
@@ -148,7 +150,8 @@ Desktop local mode uses an in-process runtime manager (`src/local-runtime.ts`) t
|
||||
8. `registerDeepLinkProtocol()`
|
||||
9. `setupDeepLinkHandler(mainWindow)`
|
||||
10. `setupAutoUpdater(mainWindow)`
|
||||
11. `mainWindow.maximize()` when restored state was maximized
|
||||
11. `startUpdateCheckInterval(mainWindow)` (4-hour periodic background checks)
|
||||
12. `mainWindow.maximize()` when restored state was maximized
|
||||
|
||||
### Window state and close-to-tray behavior
|
||||
|
||||
@@ -163,6 +166,7 @@ Desktop local mode uses an in-process runtime manager (`src/local-runtime.ts`) t
|
||||
### Quit cleanup
|
||||
|
||||
- `before-quit` sets `app.isQuitting = true`
|
||||
- Periodic updater interval is disposed
|
||||
- Tray instance is destroyed (`tray.destroy()`)
|
||||
- `mainWindow` is nulled on `closed` for clean re-creation on macOS `activate`
|
||||
|
||||
@@ -182,6 +186,8 @@ Desktop local mode uses an in-process runtime manager (`src/local-runtime.ts`) t
|
||||
- `onDeepLink(callback)`
|
||||
- `onUpdateAvailable(callback)`
|
||||
- `onUpdateDownloaded(callback)`
|
||||
- `onUpdateNotAvailable(callback)`
|
||||
- `onUpdateError(callback)`
|
||||
- `window.fusionShell`
|
||||
- `getState()`, `listProfiles()`, `saveProfile()`, `deleteProfile()`
|
||||
- `setActiveProfile()`, `setDesktopMode()`
|
||||
@@ -234,8 +240,8 @@ renderer (window.fusionAPI)
|
||||
|
||||
The desktop shell installs a native menu with standard shortcuts.
|
||||
|
||||
- **macOS:** App, Edit, View, Window, and Help menus.
|
||||
- **Windows/Linux:** Edit, View, Window, and Help (no App menu).
|
||||
- **macOS:** App, Edit, View, Window, and Help menus (App menu includes **Check for Updates…**).
|
||||
- **Windows/Linux:** Edit, View, Window, and Help (Help includes **Check for Updates…**).
|
||||
- Keyboard shortcuts use Electron `CmdOrCtrl` accelerators for cross-platform behavior.
|
||||
- View menu includes reload, force reload, dev tools toggle, and zoom controls.
|
||||
|
||||
@@ -249,7 +255,10 @@ The desktop shell installs a native menu with standard shortcuts.
|
||||
- **Desktop notifications**
|
||||
- `showDesktopNotification(title, body, options?)` wraps Electron `Notification` with support checks and optional click callback wiring.
|
||||
- **Auto-updater integration**
|
||||
- `setupAutoUpdater(mainWindow?)` configures `electron-updater`, checks for updates, and relays `update-available` / `update-downloaded` events to the renderer via IPC.
|
||||
- `setupAutoUpdater(mainWindow?)` is idempotent, binds updater listeners once, and runs the initial check only once.
|
||||
- `triggerUpdateCheck(mainWindow?)` performs on-demand checks (manual menu/IPC trigger) and returns `checking`/`unavailable`/`error` status.
|
||||
- `startUpdateCheckInterval(mainWindow, intervalMs?)` schedules periodic background checks (default every 4 hours) and returns a disposer for quit cleanup.
|
||||
- Events forwarded to renderer include `update-available`, `update-downloaded`, `update-not-available`, and `update-error`.
|
||||
- Failures are logged and treated as non-fatal (important for unsigned/local dev builds).
|
||||
- **Window state persistence**
|
||||
- `loadWindowState()` reads `window-state.json` from `app.getPath("userData")`.
|
||||
@@ -292,6 +301,8 @@ FN-1076 depends on these exact exports and names.
|
||||
| `showImportSettingsDialog` | `(parentWindow?) => Promise<string \| null>` |
|
||||
| `showDesktopNotification` | `(title, body, options?) => void` |
|
||||
| `setupAutoUpdater` | `(mainWindow?) => void` |
|
||||
| `triggerUpdateCheck` | `(mainWindow?) => Promise<{ status: "checking" } \| { status: "unavailable"; reason: string } \| { status: "error"; error: string }>` |
|
||||
| `startUpdateCheckInterval` | `(mainWindow, intervalMs?) => () => void` |
|
||||
| `loadWindowState` | `() => Promise<WindowState \| null>` |
|
||||
| `saveWindowState` | `(mainWindow) => void` |
|
||||
| `loadDesktopLaunchMode` | `() => Promise<"choose" \| "local" \| "remote">` |
|
||||
|
||||
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => {
|
||||
const showExportSettingsDialog = vi.fn();
|
||||
const showImportSettingsDialog = vi.fn();
|
||||
const setupAutoUpdater = vi.fn();
|
||||
const triggerUpdateCheck = vi.fn(async () => ({ status: "checking" as const }));
|
||||
const readShellSettings = vi.fn(async () => ({
|
||||
desktopMode: "remote",
|
||||
hasCompletedModeSelection: true,
|
||||
@@ -33,6 +34,7 @@ const mocks = vi.hoisted(() => {
|
||||
showExportSettingsDialog,
|
||||
showImportSettingsDialog,
|
||||
setupAutoUpdater,
|
||||
triggerUpdateCheck,
|
||||
readShellSettings,
|
||||
writeShellSettings,
|
||||
};
|
||||
@@ -51,6 +53,7 @@ vi.mock("../native.js", () => ({
|
||||
showExportSettingsDialog: mocks.showExportSettingsDialog,
|
||||
showImportSettingsDialog: mocks.showImportSettingsDialog,
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
triggerUpdateCheck: mocks.triggerUpdateCheck,
|
||||
}));
|
||||
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
@@ -116,8 +119,17 @@ describe("ipc handlers", () => {
|
||||
mocks.ipcHandlers.clear();
|
||||
});
|
||||
|
||||
it("registers shell channels", async () => {
|
||||
await registerHandlers();
|
||||
it("app:checkForUpdates delegates to triggerUpdateCheck", async () => {
|
||||
const { window } = await registerHandlers();
|
||||
const handler = mocks.ipcHandlers.get("app:checkForUpdates");
|
||||
mocks.triggerUpdateCheck.mockResolvedValueOnce({ status: "checking" });
|
||||
|
||||
await expect(handler?.({})).resolves.toEqual({ status: "checking" });
|
||||
expect(mocks.triggerUpdateCheck).toHaveBeenCalledWith(window);
|
||||
expect(mocks.setupAutoUpdater).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers shell channels", async () => { await registerHandlers();
|
||||
|
||||
const channels = new Set(mocks.ipcMain.handle.mock.calls.map(([channel]) => channel));
|
||||
expect(channels.has("shell:getState")).toBe(true);
|
||||
|
||||
@@ -95,6 +95,11 @@ const mocks = vi.hoisted(() => {
|
||||
const setupAutoUpdater = vi.fn(() => {
|
||||
callLog.push("setupAutoUpdater");
|
||||
});
|
||||
const stopUpdateCheckInterval = vi.fn();
|
||||
const startUpdateCheckInterval = vi.fn(() => {
|
||||
callLog.push("startUpdateCheckInterval");
|
||||
return stopUpdateCheckInterval;
|
||||
});
|
||||
|
||||
const loadWindowState = vi.fn(async () => {
|
||||
callLog.push("loadWindowState");
|
||||
@@ -135,6 +140,8 @@ const mocks = vi.hoisted(() => {
|
||||
registerDeepLinkProtocol,
|
||||
setupDeepLinkHandler,
|
||||
setupAutoUpdater,
|
||||
startUpdateCheckInterval,
|
||||
stopUpdateCheckInterval,
|
||||
loadWindowState,
|
||||
loadDesktopLaunchMode,
|
||||
saveDesktopLaunchMode,
|
||||
@@ -177,6 +184,7 @@ vi.mock("../native.js", () => ({
|
||||
saveDesktopLaunchMode: mocks.saveDesktopLaunchMode,
|
||||
saveWindowState: mocks.saveWindowState,
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
startUpdateCheckInterval: mocks.startUpdateCheckInterval,
|
||||
DEFAULT_WINDOW_STATE: mocks.DEFAULT_WINDOW_STATE,
|
||||
normalizeDesktopRemoteLaunch: vi.fn((settings) => {
|
||||
const active = settings.profiles.find((profile: { id: string }) => profile.id === settings.activeProfileId);
|
||||
@@ -258,6 +266,7 @@ describe("main integration", () => {
|
||||
"registerDeepLinkProtocol",
|
||||
"setupDeepLinkHandler",
|
||||
"setupAutoUpdater",
|
||||
"startUpdateCheckInterval",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -319,6 +328,7 @@ describe("main integration", () => {
|
||||
mainWindow: instance,
|
||||
appName: "Fusion",
|
||||
onChangeLaunchMode: expect.any(Function),
|
||||
onCheckForUpdates: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -367,7 +377,7 @@ describe("main integration", () => {
|
||||
expect(mocks.saveWindowState).toHaveBeenCalledWith(instance);
|
||||
});
|
||||
|
||||
it("before-quit destroys tray and marks app as quitting", async () => {
|
||||
it("before-quit disposes update interval, destroys tray and marks app as quitting", async () => {
|
||||
const { run } = await importMainModule();
|
||||
|
||||
run();
|
||||
@@ -378,6 +388,7 @@ describe("main integration", () => {
|
||||
|
||||
const [trayInstance] = mocks.trayInstances;
|
||||
expect(mocks.app.isQuitting).toBe(true);
|
||||
expect(mocks.stopUpdateCheckInterval).toHaveBeenCalledTimes(1);
|
||||
expect(trayInstance.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ vi.mock("../native.js", () => ({
|
||||
saveDesktopLaunchMode: vi.fn(async () => undefined),
|
||||
saveWindowState: vi.fn(),
|
||||
setupAutoUpdater: vi.fn(),
|
||||
startUpdateCheckInterval: vi.fn(() => vi.fn()),
|
||||
clampWindowStateToVisibleDisplay: vi.fn((state) => state),
|
||||
}));
|
||||
vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() }));
|
||||
|
||||
@@ -84,6 +84,7 @@ vi.mock("../native.js", () => ({
|
||||
saveDesktopLaunchMode: mocks.saveDesktopLaunchMode,
|
||||
saveWindowState: mocks.saveWindowState,
|
||||
setupAutoUpdater: mocks.setupAutoUpdater,
|
||||
startUpdateCheckInterval: vi.fn(() => vi.fn()),
|
||||
clampWindowStateToVisibleDisplay: vi.fn((state) => state),
|
||||
}));
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ vi.mock("../native.js", () => ({
|
||||
saveDesktopLaunchMode: mainDeps.saveDesktopLaunchMode,
|
||||
saveWindowState: mainDeps.saveWindowState,
|
||||
setupAutoUpdater: mainDeps.setupAutoUpdater,
|
||||
startUpdateCheckInterval: vi.fn(() => vi.fn()),
|
||||
clampWindowStateToVisibleDisplay: vi.fn((state, displays) => {
|
||||
if (state.x === undefined || state.y === undefined) {
|
||||
return state;
|
||||
|
||||
@@ -87,11 +87,13 @@ describe("application menu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("macOS template includes App menu with About, Preferences and Quit", async () => {
|
||||
it("macOS template includes App menu with About, Preferences, update check and Quit", async () => {
|
||||
const onCheckForUpdates = vi.fn();
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
onCheckForUpdates,
|
||||
});
|
||||
|
||||
expect(template[0]?.label).toBe("Fusion");
|
||||
@@ -99,12 +101,17 @@ describe("application menu", () => {
|
||||
const appMenu = template[0]?.submenu as MenuItemConstructorOptions[];
|
||||
expect(appMenu.some((item) => item.label === "About Fusion")).toBe(true);
|
||||
expect(appMenu.some((item) => item.label === "Preferences")).toBe(true);
|
||||
const checkForUpdates = appMenu.find((item) => item.label === "Check for Updates…");
|
||||
expect(checkForUpdates).toBeDefined();
|
||||
|
||||
const preferences = appMenu.find((item) => item.label === "Preferences");
|
||||
const quit = appMenu.find((item) => item.label === "Quit Fusion");
|
||||
|
||||
expect(preferences?.accelerator).toBe("CmdOrCtrl+,");
|
||||
expect(quit?.accelerator).toBe("CmdOrCtrl+Q");
|
||||
|
||||
checkForUpdates?.click?.({} as never, {} as never, {} as never);
|
||||
expect(onCheckForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Edit menu includes standard editing shortcuts", async () => {
|
||||
@@ -191,16 +198,26 @@ describe("application menu", () => {
|
||||
expect(findMenuItem(template, "Hide Fusion")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Help menu contains Fusion Documentation link", async () => {
|
||||
it("Help menu contains update check and Fusion Documentation link on non-macOS", async () => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
const onCheckForUpdates = vi.fn();
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
onCheckForUpdates,
|
||||
});
|
||||
|
||||
const docsItem = findMenuItem(template, "Fusion Documentation");
|
||||
const checkForUpdates = findMenuItem(template, "Check for Updates…");
|
||||
|
||||
expect(docsItem).toBeDefined();
|
||||
expect(checkForUpdates).toBeDefined();
|
||||
checkForUpdates?.click?.({} as never, {} as never, {} as never);
|
||||
expect(onCheckForUpdates).toHaveBeenCalledTimes(1);
|
||||
docsItem?.click?.({} as never, {} as never, {} as never);
|
||||
expect(mocks.shell.openExternal).toHaveBeenCalledWith(
|
||||
"https://github.com/Runfusion/Fusion#readme",
|
||||
|
||||
@@ -319,6 +319,7 @@ describe("native integrations", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("update-available", expect.any(Function));
|
||||
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("update-downloaded", expect.any(Function));
|
||||
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("update-not-available", expect.any(Function));
|
||||
expect(mocks.autoUpdater.on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -358,13 +359,33 @@ describe("native integrations", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("error handler does not crash", async () => {
|
||||
it("update-not-available triggers notification and renderer IPC", async () => {
|
||||
const { setupAutoUpdater } = await importNativeModule();
|
||||
|
||||
setupAutoUpdater(mocks.browserWindow as never);
|
||||
await vi.dynamicImportSettled();
|
||||
mocks.updaterHandlers.get("update-not-available")?.({ version: "1.2.0" });
|
||||
|
||||
const latestNotification = mocks.notificationInstances.at(-1);
|
||||
expect(latestNotification?.options).toMatchObject({
|
||||
title: "Fusion is up to date",
|
||||
body: "You're on the latest version",
|
||||
silent: true,
|
||||
});
|
||||
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith(
|
||||
"update-not-available",
|
||||
expect.objectContaining({ version: "1.2.0" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("error handler sends renderer IPC and does not crash", async () => {
|
||||
const { setupAutoUpdater } = await importNativeModule();
|
||||
|
||||
setupAutoUpdater(mocks.browserWindow as never);
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
expect(() => mocks.updaterHandlers.get("error")?.(new Error("network"))).not.toThrow();
|
||||
expect(mocks.browserWindow.webContents.send).toHaveBeenCalledWith("update-error", { message: "network" });
|
||||
});
|
||||
|
||||
it("catches checkForUpdates rejection", async () => {
|
||||
@@ -375,6 +396,22 @@ describe("native integrations", () => {
|
||||
await vi.dynamicImportSettled();
|
||||
});
|
||||
|
||||
it("calling setupAutoUpdater twice does not rebind listeners or rerun initial check", async () => {
|
||||
const { setupAutoUpdater } = await importNativeModule();
|
||||
|
||||
setupAutoUpdater(mocks.browserWindow as never);
|
||||
await vi.dynamicImportSettled();
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
setupAutoUpdater(mocks.browserWindow as never);
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
expect(mocks.autoUpdater.on).toHaveBeenCalledTimes(4);
|
||||
expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("wraps setup in try/catch when updater throws during registration", async () => {
|
||||
const { setupAutoUpdater } = await importNativeModule();
|
||||
mocks.autoUpdater.on.mockImplementationOnce(() => {
|
||||
@@ -386,6 +423,62 @@ describe("native integrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerUpdateCheck", () => {
|
||||
it("returns checking when checkForUpdates succeeds", async () => {
|
||||
const { triggerUpdateCheck } = await importNativeModule();
|
||||
|
||||
await expect(triggerUpdateCheck(mocks.browserWindow as never)).resolves.toEqual({ status: "checking" });
|
||||
});
|
||||
|
||||
it("returns error when checkForUpdates rejects", async () => {
|
||||
const { triggerUpdateCheck } = await importNativeModule();
|
||||
mocks.autoUpdater.checkForUpdates.mockRejectedValue(new Error("network down"));
|
||||
|
||||
await expect(triggerUpdateCheck(mocks.browserWindow as never)).resolves.toEqual({
|
||||
status: "error",
|
||||
error: "network down",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unavailable when autoUpdater export is missing", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("electron-updater", () => ({ default: {} }));
|
||||
|
||||
const { triggerUpdateCheck } = await importNativeModule();
|
||||
|
||||
await expect(triggerUpdateCheck(mocks.browserWindow as never)).resolves.toEqual({
|
||||
status: "unavailable",
|
||||
reason: "updater_unavailable",
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("electron-updater", () => ({
|
||||
default: { autoUpdater: mocks.autoUpdater },
|
||||
autoUpdater: mocks.autoUpdater,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("startUpdateCheckInterval", () => {
|
||||
it("schedules interval, triggers checks, and clears timer with stable disposer per window", async () => {
|
||||
const { startUpdateCheckInterval } = await importNativeModule();
|
||||
|
||||
const disposerA = startUpdateCheckInterval(mocks.browserWindow as never, 1_000);
|
||||
const disposerB = startUpdateCheckInterval(mocks.browserWindow as never, 1_000);
|
||||
expect(disposerB).toBe(disposerA);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalled();
|
||||
|
||||
disposerA();
|
||||
const callsAfterDispose = mocks.autoUpdater.checkForUpdates.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(mocks.autoUpdater.checkForUpdates.mock.calls.length).toBe(callsAfterDispose);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop launch mode", () => {
|
||||
it("loadDesktopLaunchMode returns choose when file is missing", async () => {
|
||||
const { loadDesktopLaunchMode } = await importNativeModule();
|
||||
|
||||
@@ -70,8 +70,40 @@ describe("preload", () => {
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:openConnectionManager");
|
||||
});
|
||||
|
||||
it("fusionShell delegates connection-management methods to IPC", async () => {
|
||||
it("electronAPI exposes update-not-available and update-error listeners", async () => {
|
||||
await importPreloadModule();
|
||||
const api = getExposed<{
|
||||
onUpdateNotAvailable: (listener: (info: { version?: string }) => void) => () => void;
|
||||
onUpdateError: (listener: (info: { message: string }) => void) => () => void;
|
||||
}>("electronAPI");
|
||||
|
||||
const onNotAvailable = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
const unsubscribeNotAvailable = api?.onUpdateNotAvailable(onNotAvailable);
|
||||
const unsubscribeError = api?.onUpdateError(onError);
|
||||
|
||||
const notAvailableHandler = mocks.ipcRenderer.on.mock.calls.find(([channel]) => channel === "update-not-available")?.[1] as
|
||||
| ((event: unknown, info: { version?: string }) => void)
|
||||
| undefined;
|
||||
const errorHandler = mocks.ipcRenderer.on.mock.calls.find(([channel]) => channel === "update-error")?.[1] as
|
||||
| ((event: unknown, info: { message: string }) => void)
|
||||
| undefined;
|
||||
|
||||
notAvailableHandler?.({} as never, { version: "1.2.3" });
|
||||
errorHandler?.({} as never, { message: "network" });
|
||||
|
||||
expect(onNotAvailable).toHaveBeenCalledWith({ version: "1.2.3" });
|
||||
expect(onError).toHaveBeenCalledWith({ message: "network" });
|
||||
|
||||
unsubscribeNotAvailable?.();
|
||||
unsubscribeError?.();
|
||||
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("update-not-available", expect.any(Function));
|
||||
expect(mocks.ipcRenderer.removeListener).toHaveBeenCalledWith("update-error", expect.any(Function));
|
||||
});
|
||||
|
||||
it("fusionShell delegates connection-management methods to IPC", async () => { await importPreloadModule();
|
||||
const shell = getExposed<{
|
||||
getState: () => Promise<unknown>;
|
||||
listProfiles: () => Promise<unknown>;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
|
||||
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog, type NormalizedDesktopRemoteLaunch } from "./native.js";
|
||||
import {
|
||||
showExportSettingsDialog,
|
||||
showImportSettingsDialog,
|
||||
triggerUpdateCheck,
|
||||
type NormalizedDesktopRemoteLaunch,
|
||||
} from "./native.js";
|
||||
import { type EngineStatus, updateTrayStatus } from "./tray.js";
|
||||
import {
|
||||
applyDeleteProfile,
|
||||
@@ -95,10 +100,9 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
||||
appVersion: app.getVersion(),
|
||||
}));
|
||||
|
||||
ipcMain.handle("app:checkForUpdates", () => {
|
||||
ipcMain.handle("app:checkForUpdates", async () => {
|
||||
try {
|
||||
setupAutoUpdater(mainWindow);
|
||||
return { status: "checking" as const };
|
||||
return await triggerUpdateCheck(mainWindow);
|
||||
} catch (error) {
|
||||
return { status: "error" as const, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
saveDesktopLaunchMode,
|
||||
saveWindowState,
|
||||
setupAutoUpdater,
|
||||
triggerUpdateCheck,
|
||||
startUpdateCheckInterval,
|
||||
normalizeDesktopRemoteLaunch,
|
||||
buildRemoteShellHandoffUrl,
|
||||
clampWindowStateToVisibleDisplay,
|
||||
@@ -46,6 +48,7 @@ let localRuntimeManager: LocalRuntimeManager | null = null;
|
||||
let currentDesktopLaunchMode: DesktopLaunchMode = "choose";
|
||||
let currentRemoteLaunch: NormalizedDesktopRemoteLaunch | null = null;
|
||||
let localRuntimeStartupAttempted = false;
|
||||
let stopUpdateCheckInterval: (() => void) | null = null;
|
||||
|
||||
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
|
||||
return app as Electron.App & AppWithQuitFlag;
|
||||
@@ -216,6 +219,9 @@ export async function initializeApp(): Promise<void> {
|
||||
onChangeLaunchMode: async () => {
|
||||
await resetLaunchModeAndReload(createdWindow);
|
||||
},
|
||||
onCheckForUpdates: async () => {
|
||||
await triggerUpdateCheck(createdWindow);
|
||||
},
|
||||
});
|
||||
|
||||
tray = new Tray(nativeImage.createEmpty());
|
||||
@@ -265,6 +271,7 @@ export async function initializeApp(): Promise<void> {
|
||||
registerDeepLinkProtocol();
|
||||
setupDeepLinkHandler(createdWindow);
|
||||
setupAutoUpdater(createdWindow);
|
||||
stopUpdateCheckInterval = startUpdateCheckInterval(createdWindow);
|
||||
|
||||
if (windowState?.isMaximized === true) {
|
||||
createdWindow.maximize();
|
||||
@@ -285,6 +292,12 @@ export function run(): void {
|
||||
|
||||
app.on("before-quit", () => {
|
||||
appWithQuitFlag.isQuitting = true;
|
||||
|
||||
if (stopUpdateCheckInterval) {
|
||||
stopUpdateCheckInterval();
|
||||
stopUpdateCheckInterval = null;
|
||||
}
|
||||
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AppMenuOptions {
|
||||
mainWindow: BrowserWindow;
|
||||
appName: string;
|
||||
onChangeLaunchMode?: () => Promise<void> | void;
|
||||
onCheckForUpdates?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
function buildConnectionSubmenu(options: AppMenuOptions): MenuItemConstructorOptions {
|
||||
@@ -35,6 +36,15 @@ function buildAppSubmenu(options: AppMenuOptions): MenuItemConstructorOptions {
|
||||
{
|
||||
label: `About ${options.appName}`,
|
||||
},
|
||||
{
|
||||
label: "Check for Updates…",
|
||||
click: () => {
|
||||
if (!options.onCheckForUpdates) return;
|
||||
void Promise.resolve(options.onCheckForUpdates()).catch((error: unknown) => {
|
||||
console.error("[desktop/menu] onCheckForUpdates failed", error);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
@@ -217,10 +227,19 @@ function buildWindowSubmenu(isMac: boolean): MenuItemConstructorOptions {
|
||||
};
|
||||
}
|
||||
|
||||
function buildHelpSubmenu(): MenuItemConstructorOptions {
|
||||
function buildHelpSubmenu(options: AppMenuOptions): MenuItemConstructorOptions {
|
||||
return {
|
||||
label: "Help",
|
||||
submenu: [
|
||||
{
|
||||
label: "Check for Updates…",
|
||||
click: () => {
|
||||
if (!options.onCheckForUpdates) return;
|
||||
void Promise.resolve(options.onCheckForUpdates()).catch((error: unknown) => {
|
||||
console.error("[desktop/menu] onCheckForUpdates failed", error);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Fusion Documentation",
|
||||
click: () => {
|
||||
@@ -239,7 +258,7 @@ export function buildMenuTemplate(options: AppMenuOptions): MenuItemConstructorO
|
||||
buildViewSubmenu(options),
|
||||
buildConnectionSubmenu(options),
|
||||
buildWindowSubmenu(isMac),
|
||||
buildHelpSubmenu(),
|
||||
buildHelpSubmenu(options),
|
||||
];
|
||||
|
||||
if (isMac) {
|
||||
|
||||
@@ -212,57 +212,176 @@ export function showDesktopNotification(
|
||||
}
|
||||
}
|
||||
|
||||
type AutoUpdaterLike = {
|
||||
autoDownload: boolean;
|
||||
autoInstallOnAppQuit: boolean;
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => unknown;
|
||||
checkForUpdates: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
let cachedAutoUpdater: AutoUpdaterLike | null = null;
|
||||
let autoUpdaterLoadPromise: Promise<AutoUpdaterLike | null> | null = null;
|
||||
let listenersBound = false;
|
||||
let hasRunInitialUpdateCheck = false;
|
||||
let autoUpdaterWindow: BrowserWindow | undefined;
|
||||
let updateIntervalTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let updateIntervalDisposer: (() => void) | null = null;
|
||||
let updateIntervalWindow: BrowserWindow | null = null;
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
async function resolveAutoUpdater(): Promise<AutoUpdaterLike | null> {
|
||||
if (cachedAutoUpdater) {
|
||||
return cachedAutoUpdater;
|
||||
}
|
||||
|
||||
if (!autoUpdaterLoadPromise) {
|
||||
autoUpdaterLoadPromise = (async () => {
|
||||
try {
|
||||
const mod = (await import("electron-updater")) as {
|
||||
default?: { autoUpdater?: unknown };
|
||||
autoUpdater?: unknown;
|
||||
};
|
||||
const namedAutoUpdater = "autoUpdater" in mod ? (mod as { autoUpdater?: unknown }).autoUpdater : undefined;
|
||||
const autoUpdater = (mod.default?.autoUpdater ?? namedAutoUpdater) as AutoUpdaterLike | undefined;
|
||||
|
||||
if (!autoUpdater) {
|
||||
console.warn("[desktop/native] Auto-updater module loaded without autoUpdater export");
|
||||
return null;
|
||||
}
|
||||
|
||||
cachedAutoUpdater = autoUpdater;
|
||||
return autoUpdater;
|
||||
} catch (error) {
|
||||
console.warn("[desktop/native] Auto-updater unavailable", error);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return autoUpdaterLoadPromise;
|
||||
}
|
||||
|
||||
function bindAutoUpdaterListeners(autoUpdater: AutoUpdaterLike): void {
|
||||
if (listenersBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
listenersBound = true;
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
showDesktopNotification("Fusion Update Available", "Update available — downloading in background", {
|
||||
silent: true,
|
||||
});
|
||||
autoUpdaterWindow?.webContents.send("update-available", info);
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
showDesktopNotification("Fusion Update Ready", "Update ready — will install on quit", {
|
||||
silent: true,
|
||||
});
|
||||
autoUpdaterWindow?.webContents.send("update-downloaded", info);
|
||||
});
|
||||
|
||||
autoUpdater.on("update-not-available", (info) => {
|
||||
showDesktopNotification("Fusion is up to date", "You're on the latest version", {
|
||||
silent: true,
|
||||
});
|
||||
autoUpdaterWindow?.webContents.send("update-not-available", info);
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (error) => {
|
||||
const message = getErrorMessage(error);
|
||||
console.error("[desktop/native] Auto-updater error", error);
|
||||
autoUpdaterWindow?.webContents.send("update-error", { message });
|
||||
});
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(mainWindow?: BrowserWindow): void {
|
||||
if (mainWindow) {
|
||||
autoUpdaterWindow = mainWindow;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const mod = (await import("electron-updater")) as {
|
||||
default?: { autoUpdater?: unknown };
|
||||
autoUpdater?: unknown;
|
||||
};
|
||||
const autoUpdater = (mod.default?.autoUpdater ?? mod.autoUpdater) as
|
||||
| {
|
||||
autoDownload: boolean;
|
||||
autoInstallOnAppQuit: boolean;
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => unknown;
|
||||
checkForUpdates: () => Promise<unknown>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const autoUpdater = await resolveAutoUpdater();
|
||||
if (!autoUpdater) {
|
||||
console.warn("[desktop/native] Auto-updater module loaded without autoUpdater export");
|
||||
return;
|
||||
}
|
||||
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
bindAutoUpdaterListeners(autoUpdater);
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
showDesktopNotification("Fusion Update Available", "Update available — downloading in background", {
|
||||
silent: true,
|
||||
});
|
||||
mainWindow?.webContents.send("update-available", info);
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
showDesktopNotification("Fusion Update Ready", "Update ready — will install on quit", {
|
||||
silent: true,
|
||||
});
|
||||
mainWindow?.webContents.send("update-downloaded", info);
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (error) => {
|
||||
console.error("[desktop/native] Auto-updater error", error);
|
||||
});
|
||||
if (hasRunInitialUpdateCheck) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasRunInitialUpdateCheck = true;
|
||||
await autoUpdater.checkForUpdates().catch((error: unknown) => {
|
||||
console.error("[desktop/native] Auto-updater check failed", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[desktop/native] Auto-updater unavailable", error);
|
||||
console.warn("[desktop/native] Auto-updater setup failed", error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export async function triggerUpdateCheck(
|
||||
mainWindow?: BrowserWindow,
|
||||
): Promise<{ status: "checking" } | { status: "unavailable"; reason: string } | { status: "error"; error: string }> {
|
||||
setupAutoUpdater(mainWindow);
|
||||
|
||||
const autoUpdater = await resolveAutoUpdater();
|
||||
if (!autoUpdater) {
|
||||
return { status: "unavailable", reason: "updater_unavailable" };
|
||||
}
|
||||
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
return { status: "checking" };
|
||||
} catch (error) {
|
||||
return { status: "error", error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export function startUpdateCheckInterval(mainWindow: BrowserWindow, intervalMs = 4 * 60 * 60 * 1000): () => void {
|
||||
autoUpdaterWindow = mainWindow;
|
||||
|
||||
if (updateIntervalDisposer && updateIntervalWindow === mainWindow) {
|
||||
return updateIntervalDisposer;
|
||||
}
|
||||
|
||||
if (updateIntervalTimer) {
|
||||
clearInterval(updateIntervalTimer);
|
||||
updateIntervalTimer = null;
|
||||
}
|
||||
|
||||
updateIntervalWindow = mainWindow;
|
||||
updateIntervalTimer = setInterval(() => {
|
||||
void triggerUpdateCheck(mainWindow);
|
||||
}, intervalMs);
|
||||
|
||||
updateIntervalDisposer = () => {
|
||||
if (updateIntervalTimer) {
|
||||
clearInterval(updateIntervalTimer);
|
||||
updateIntervalTimer = null;
|
||||
}
|
||||
updateIntervalDisposer = null;
|
||||
updateIntervalWindow = null;
|
||||
};
|
||||
|
||||
return updateIntervalDisposer;
|
||||
}
|
||||
|
||||
function normalizeServerBaseUrl(serverUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(serverUrl);
|
||||
|
||||
@@ -102,6 +102,16 @@ const electronApi = {
|
||||
ipcRenderer.on("update-downloaded", handler);
|
||||
return () => ipcRenderer.removeListener("update-downloaded", handler);
|
||||
},
|
||||
onUpdateNotAvailable: (callback: (info: { version?: string }) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, info: { version?: string }) => callback(info);
|
||||
ipcRenderer.on("update-not-available", handler);
|
||||
return () => ipcRenderer.removeListener("update-not-available", handler);
|
||||
},
|
||||
onUpdateError: (callback: (info: { message: string }) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, info: { message: string }) => callback(info);
|
||||
ipcRenderer.on("update-error", handler);
|
||||
return () => ipcRenderer.removeListener("update-error", handler);
|
||||
},
|
||||
invoke: (channel: string, payload?: unknown): Promise<unknown> => ipcRenderer.invoke(channel, payload),
|
||||
};
|
||||
|
||||
|
||||
5
packages/desktop/src/types.d.ts
vendored
5
packages/desktop/src/types.d.ts
vendored
@@ -7,8 +7,9 @@ export interface SystemInfo {
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
status: "checking" | "error";
|
||||
status: "checking" | "unavailable" | "error";
|
||||
error?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DeepLinkResult {
|
||||
@@ -52,6 +53,8 @@ export interface FusionAPI {
|
||||
// Auto-updater events
|
||||
onUpdateAvailable(callback: (info: { version: string }) => void): () => void;
|
||||
onUpdateDownloaded(callback: () => void): () => void;
|
||||
onUpdateNotAvailable(callback: (info: { version?: string }) => void): () => void;
|
||||
onUpdateError(callback: (info: { message: string }) => void): () => void;
|
||||
|
||||
// Generic IPC invoke bridge
|
||||
invoke(channel: string, payload?: unknown): Promise<unknown>;
|
||||
|
||||
Reference in New Issue
Block a user