diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index a474fc536..8e397e382 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -11483,7 +11483,9 @@ export function registerIpc({ }); ipcMain.handle(IPC.updateCheckForUpdates, () => { - getCtx().autoUpdateService?.checkForUpdates(); + // Only reachable from the Settings button, so it always counts as + // user-initiated: it must run even when an update is already staged. + getCtx().autoUpdateService?.checkForUpdates({ userInitiated: true }); }); ipcMain.handle(IPC.updateGetState, () => { diff --git a/apps/desktop/src/main/services/updates/autoUpdateService.test.ts b/apps/desktop/src/main/services/updates/autoUpdateService.test.ts index 062db826e..fc593cae0 100644 --- a/apps/desktop/src/main/services/updates/autoUpdateService.test.ts +++ b/apps/desktop/src/main/services/updates/autoUpdateService.test.ts @@ -420,6 +420,51 @@ describe("createAutoUpdateService", () => { service.dispose(); }); + it("still checks when an update is already staged, but only when the user asked", async () => { + // The Settings button was a silent no-op in exactly this state: an update + // downloaded and waiting for a restart. The automatic timers must keep + // standing down so they cannot disturb the staged download. + const updater = new FakeAutoUpdater(); + const service = createAutoUpdateService({ + logger: makeLogger(), + currentVersion: "1.2.60", + globalStatePath: makeStatePath(), + startupDelayMs: 60_000, + periodicCheckMs: 60_000, + now: () => "2026-08-19T21:00:00.000Z", + updater, + }); + + updater.emit("update-available", { version: "1.2.61" }); + updater.emit("update-downloaded", { version: "1.2.61" }); + expect(service.getSnapshot()).toMatchObject({ status: "ready", version: "1.2.61" }); + + updater.checkForUpdates.mockClear(); + service.checkForUpdates(); + // Flush the microtask queue rather than waitFor: a `not.toHaveBeenCalled` + // inside waitFor passes on its first tick and would assert nothing. + await Promise.resolve(); + await Promise.resolve(); + expect(updater.checkForUpdates).not.toHaveBeenCalled(); + + updater.checkForUpdates.mockImplementation(async () => ({ + updateInfo: { version: "1.2.63" }, + })); + service.checkForUpdates({ userInitiated: true }); + + await vi.waitFor(() => { + expect(updater.checkForUpdates).toHaveBeenCalledTimes(1); + // The newest version is reported, and the staged 1.2.61 is left alone. + expect(service.getSnapshot()).toMatchObject({ + status: "ready", + version: "1.2.61", + latestKnownVersion: "1.2.63", + }); + }); + + service.dispose(); + }); + it("tracks download progress and persists the target version before quit-and-install", async () => { const globalStatePath = makeStatePath(); const updater = new FakeAutoUpdater(); diff --git a/apps/desktop/src/main/services/updates/autoUpdateService.ts b/apps/desktop/src/main/services/updates/autoUpdateService.ts index c7b5630a5..29b80e434 100644 --- a/apps/desktop/src/main/services/updates/autoUpdateService.ts +++ b/apps/desktop/src/main/services/updates/autoUpdateService.ts @@ -1050,8 +1050,19 @@ export function createAutoUpdateService({ await checkPromise; } - function checkForUpdates(): void { - void runUpdateCheck(); + /** + * `userInitiated` is what separates the Settings button from the startup and + * periodic timers. The automatic checks stay out of the way of an update that + * is already downloaded and waiting for a restart, but a person pressing + * "Check for updates" is asking a question, and answering it with an early + * return is indistinguishable from the button being broken — the version on + * screen just stays at whatever the last real check found. + * + * The `ready` branch of the check still returns before downloading, so this + * refreshes the newest known version without disturbing the staged download. + */ + function checkForUpdates(options: { userInitiated?: boolean } = {}): void { + void runUpdateCheck({ allowReady: options.userInitiated === true }); } async function refreshReadyUpdateBeforeInstall(): Promise {