feat(FN-5022): merge fusion/fn-5022

This commit is contained in:
gsxdsm
2026-05-18 05:46:37 -07:00
parent 54bb1651b7
commit dd0d4b74b7
15 changed files with 400 additions and 53 deletions

View File

@@ -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">` |

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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() }));

View File

@@ -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),
}));

View File

@@ -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;

View File

@@ -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",

View File

@@ -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();

View File

@@ -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>;

View File

@@ -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) };
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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),
};

View File

@@ -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>;