diff --git a/docs/release.md b/docs/release.md index 90dd263e3..ed868179c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -169,13 +169,13 @@ This does **not** apply to fresh releases cut via `npm run release:patch` — th ### Releasing during an active rollout -If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. +If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it. Rollout-aware clients revalidate the manifest before installing a downloaded update on quit. If N+1 has replaced N but the client is not admitted to N+1 yet, it skips the downloaded N and waits rather than installing two updates in succession. If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1. -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast. ### Limitations -- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch. +- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch. - **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix. - **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later. - **Up to ~30 min automatic admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. Clicking **Check** is manual and bypasses rollout admission. diff --git a/packages/desktop/src/daemon/quit-lifecycle.test.ts b/packages/desktop/src/daemon/quit-lifecycle.test.ts index d9af82b65..4d921c443 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.test.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { @@ -23,96 +23,151 @@ describe("quit-lifecycle", () => { }); it("short-circuits without inspecting the daemon when keep-running is on", async () => { - const isDesktopManagedDaemonRunning = vi.fn(() => true); - const stopDaemon = vi.fn(async () => undefined); - const showShutdownFeedback = vi.fn(); + const events: string[] = []; const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ settingsStore: { get: async () => SETTINGS_KEEP_RUNNING }, - isDesktopManagedDaemonRunning, - stopDaemon, - showShutdownFeedback, + isDesktopManagedDaemonRunning: () => { + events.push("inspect"); + return true; + }, + stopDaemon: async () => { + events.push("stop"); + }, + showShutdownFeedback: () => { + events.push("feedback"); + }, }); expect(stopped).toBe(false); - expect(isDesktopManagedDaemonRunning).not.toHaveBeenCalled(); - expect(stopDaemon).not.toHaveBeenCalled(); - expect(showShutdownFeedback).not.toHaveBeenCalled(); + expect(events).toEqual([]); }); it("does not stop a manually started daemon on quit", async () => { - const stopDaemon = vi.fn(async () => undefined); - const showShutdownFeedback = vi.fn(); + const events: string[] = []; const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT }, isDesktopManagedDaemonRunning: () => false, - stopDaemon, - showShutdownFeedback, + stopDaemon: async () => { + events.push("stop"); + }, + showShutdownFeedback: () => { + events.push("feedback"); + }, }); expect(stopped).toBe(false); - expect(stopDaemon).not.toHaveBeenCalled(); - expect(showShutdownFeedback).not.toHaveBeenCalled(); + expect(events).toEqual([]); }); it("shows feedback then stops a desktop-managed daemon", async () => { - const stopDaemon = vi.fn(async () => undefined); - const showShutdownFeedback = vi.fn(); + const events: string[] = []; const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ settingsStore: { get: async () => SETTINGS_STOP_ON_QUIT }, isDesktopManagedDaemonRunning: () => true, - stopDaemon, - showShutdownFeedback, + stopDaemon: async () => { + events.push("stop"); + }, + showShutdownFeedback: () => { + events.push("feedback"); + }, }); expect(stopped).toBe(true); - expect(showShutdownFeedback).toHaveBeenCalledTimes(1); - expect(stopDaemon).toHaveBeenCalledTimes(1); - expect(showShutdownFeedback.mock.invocationCallOrder[0]).toBeLessThan( - stopDaemon.mock.invocationCallOrder[0], - ); + expect(events).toEqual(["feedback", "stop"]); }); - it("preventDefaults the first quit, runs the async stop decision, then exits hard", async () => { + it("revalidates updates after daemon shutdown before exiting", async () => { let resolveStopDecision: (() => void) | null = null; - const app = { exit: vi.fn() }; - const closeTransportSessions = vi.fn(); - const onStopError = vi.fn(); - const preventDefault = vi.fn(); - const secondPreventDefault = vi.fn(); + let resolveUpdateDecision: (() => void) | null = null; + const events: string[] = []; const handleBeforeQuit = createBeforeQuitHandler({ - app, - closeTransportSessions, - stopDesktopManagedDaemonIfNeeded: vi.fn( - () => - new Promise((resolve) => { - resolveStopDecision = () => resolve(false); - }), - ), - onStopError, + app: { + exit: (code) => { + events.push(`exit:${code}`); + }, + }, + closeTransportSessions: () => { + events.push("close-transports"); + }, + stopDesktopManagedDaemonIfNeeded: () => + new Promise((resolve) => { + resolveStopDecision = () => { + events.push("daemon-stopped"); + resolve(false); + }; + }), + installAppUpdateOnQuit: () => + new Promise((resolve) => { + resolveUpdateDecision = () => { + events.push("update-checked"); + resolve(false); + }; + }), + onStopError: () => { + events.push("stop-error"); + }, + onUpdateError: () => { + events.push("update-error"); + }, }); - handleBeforeQuit({ preventDefault }); + handleBeforeQuit({ + preventDefault: () => { + events.push("prevent-default"); + }, + }); - expect(preventDefault).toHaveBeenCalledTimes(1); - expect(closeTransportSessions).toHaveBeenCalledTimes(1); - expect(app.exit).not.toHaveBeenCalled(); + expect(events).toEqual(["close-transports", "prevent-default"]); expect(resolveStopDecision).not.toBeNull(); resolveStopDecision?.(); await Promise.resolve(); await Promise.resolve(); - expect(app.exit).toHaveBeenCalledWith(0); - expect(onStopError).not.toHaveBeenCalled(); + expect(events).toEqual(["close-transports", "prevent-default", "daemon-stopped"]); + expect(resolveUpdateDecision).not.toBeNull(); - handleBeforeQuit({ preventDefault: secondPreventDefault }); + resolveUpdateDecision?.(); + await Promise.resolve(); + await Promise.resolve(); - expect(secondPreventDefault).not.toHaveBeenCalled(); - expect(closeTransportSessions).toHaveBeenCalledTimes(2); - expect(app.exit).toHaveBeenCalledTimes(1); + expect(events).toEqual([ + "close-transports", + "prevent-default", + "daemon-stopped", + "update-checked", + "exit:0", + ]); + + handleBeforeQuit({ + preventDefault: () => { + events.push("second-prevent-default"); + }, + }); + + expect(events.at(-1)).toBe("close-transports"); + expect(events).not.toContain("second-prevent-default"); + }); + + it("lets the updater own process exit when a validated update is installing", async () => { + const exits: number[] = []; + const handleBeforeQuit = createBeforeQuitHandler({ + app: { exit: (code) => exits.push(code) }, + closeTransportSessions: () => {}, + stopDesktopManagedDaemonIfNeeded: async () => false, + installAppUpdateOnQuit: async () => true, + onStopError: () => {}, + onUpdateError: () => {}, + }); + + handleBeforeQuit({ preventDefault: () => {} }); + await Promise.resolve(); + await Promise.resolve(); + + expect(exits).toEqual([]); }); }); diff --git a/packages/desktop/src/daemon/quit-lifecycle.ts b/packages/desktop/src/daemon/quit-lifecycle.ts index 879f59778..1f4a5f2a2 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.ts @@ -46,18 +46,20 @@ export function createBeforeQuitHandler({ app, closeTransportSessions, stopDesktopManagedDaemonIfNeeded, + installAppUpdateOnQuit, onStopError, + onUpdateError, }: { app: BeforeQuitApp; closeTransportSessions: () => void; stopDesktopManagedDaemonIfNeeded: () => Promise; + installAppUpdateOnQuit: () => Promise; onStopError: (error: unknown) => void; + onUpdateError: (error: unknown) => void; }): (event: BeforeQuitEvent) => void { - // We always preventDefault on first quit so we can run the async stop - // decision, then call app.exit(0) — which bypasses Electron's - // close → window-all-closed → will-quit chain. The window-all-closed - // listener is a darwin no-op (macOS convention) and would otherwise - // veto a re-fired app.quit(). + // The first quit waits for daemon shutdown and update revalidation. A validated + // update re-fires app.quit(); otherwise app.exit(0) bypasses Electron's macOS + // window-all-closed handler, which would veto that second quit. let quitting = false; return (event) => { @@ -66,12 +68,23 @@ export function createBeforeQuitHandler({ quitting = true; event.preventDefault(); - void stopDesktopManagedDaemonIfNeeded() - .catch((error) => { + void (async () => { + try { + await stopDesktopManagedDaemonIfNeeded(); + } catch (error) { onStopError(error); - }) - .finally(() => { - app.exit(0); - }); + } + + try { + const installingUpdate = await installAppUpdateOnQuit(); + if (installingUpdate) { + return; + } + } catch (error) { + onUpdateError(error); + } + + app.exit(0); + })(); }; } diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts index f243a7c28..4000c8316 100644 --- a/packages/desktop/src/features/app-update-service.test.ts +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -17,7 +17,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { > = []; private gate: ((info: RuntimeUpdateInfo) => boolean | Promise) | null = null; private configuration: AppUpdateRuntimeConfiguration | null = null; + private downloadableUpdate: RuntimeUpdateInfo | null = null; + private downloadedUpdate: RuntimeUpdateInfo | null = null; checkCount = 0; + installedVersions: string[] = []; configure(input: AppUpdateRuntimeConfiguration): void { this.configuration = input; @@ -59,6 +62,7 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } finishUpdateDownload(info: RuntimeUpdateInfo): void { + this.downloadedUpdate = info; this.configuration?.onUpdateDownloaded(info); } @@ -80,12 +84,22 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } if (!result || !this.gate) return result; const admitted = await this.gate(result.updateInfo); - return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted }; + const isUpdateAvailable = result.isUpdateAvailable && admitted; + this.downloadableUpdate = isUpdateAvailable ? result.updateInfo : null; + return { ...result, isUpdateAvailable }; } - async downloadUpdate(): Promise {} + async downloadUpdate(): Promise { + if (this.downloadableUpdate) { + this.finishUpdateDownload(this.downloadableUpdate); + } + } - quitAndInstall(): void {} + quitAndInstall(): void { + if (this.downloadedUpdate) { + this.installedVersions.push(this.downloadedUpdate.version); + } + } } function createService(input?: { now?: () => number; bucket?: () => Promise }) { @@ -179,6 +193,111 @@ describe("app update service", () => { }); }); + it("replaces a downloaded update when a newer release is admitted", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.finishUpdateDownload(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(result).toEqual({ + hasUpdate: true, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.5", + body: null, + date: "2026-04-28T00:00:00.000Z", + errorMessage: null, + }); + }); + + it("installs the newest admitted release when quitting with an older download", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.finishUpdateDownload(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const installed = await service.installUpdateOnQuit({ + currentVersion: "1.2.3", + releaseChannel: "stable", + }); + + expect(installed).toBe(true); + expect(runtime.installedVersions).toEqual(["1.2.5"]); + }); + + it("does not install an older download while its replacement is still rolling out", async () => { + const now = Date.parse("2026-04-28T12:00:00.000Z"); + const { runtime, service } = createService({ now: () => now, bucket: async () => 0.4 }); + const olderUpdate = { + ...rolledOutUpdate, + releaseDate: "2026-04-27T00:00:00.000Z", + }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: olderUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.finishUpdateDownload(olderUpdate); + + const newerUpdate = { + ...rolledOutUpdate, + version: "1.2.5", + releaseDate: "2026-04-28T12:00:00.000Z", + }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const installed = await service.installUpdateOnQuit({ + currentVersion: "1.2.3", + releaseChannel: "stable", + }); + + expect(installed).toBe(false); + expect(runtime.installedVersions).toEqual([]); + }); + + it("rechecks for the newest release before a manual install", async () => { + const { runtime, service } = createService({ bucket: async () => 0.99 }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + runtime.finishUpdateDownload(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const result = await service.downloadAndInstallUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + }); + + expect(result.installed).toBe(true); + expect(runtime.installedVersions).toEqual(["1.2.5"]); + }); + it("trusts the runtime availability decision before comparing versions", async () => { const { runtime, service } = createService({ bucket: async () => 0 }); runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate }); @@ -322,40 +441,8 @@ describe("app update service", () => { }); }); - it("keeps preparation errors emitted before the update check rejects", async () => { - const { runtime, service } = createService(); - const deferredCheck = runtime.deferNextCheck(); - const pending = service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - - runtime.prepareUpdate(rolledOutUpdate); - runtime.failRuntime(new Error("sha512 checksum mismatch")); - deferredCheck.reject(new Error("sha512 checksum mismatch")); - const checkResult = await pending; - expect(checkResult.errorMessage).toBe("sha512 checksum mismatch"); - - const automaticResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - - expect(automaticResult).toEqual({ - hasUpdate: true, - readyToInstall: false, - currentVersion: "1.2.3", - latestVersion: "1.2.4", - body: null, - date: "2026-04-28T00:00:00.000Z", - errorMessage: "sha512 checksum mismatch", - }); - }); - - it("returns runtime update errors after an update fails to prepare", async () => { - const { runtime, service } = createService(); + it("discovers newer releases after an update fails to prepare", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); await service.checkForAppUpdate({ @@ -365,6 +452,8 @@ describe("app update service", () => { }); runtime.failRuntime(new Error("sha512 checksum mismatch")); + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); const result = await service.checkForAppUpdate({ currentVersion: "1.2.3", releaseChannel: "stable", @@ -375,10 +464,10 @@ describe("app update service", () => { hasUpdate: true, readyToInstall: false, currentVersion: "1.2.3", - latestVersion: "1.2.4", + latestVersion: "1.2.5", body: null, date: "2026-04-28T00:00:00.000Z", - errorMessage: "sha512 checksum mismatch", + errorMessage: null, }); }); @@ -445,91 +534,4 @@ describe("app update service", () => { errorMessage: null, }); }); - - it("returns runtime update errors to multiple automatic checks before a manual retry clears them", async () => { - const { runtime, service } = createService(); - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); - - await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "manual", - }); - runtime.failRuntime(new Error("sha512 checksum mismatch")); - - const firstAutomaticResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - const secondAutomaticResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - - expect(firstAutomaticResult).toEqual({ - hasUpdate: true, - readyToInstall: false, - currentVersion: "1.2.3", - latestVersion: "1.2.4", - body: null, - date: "2026-04-28T00:00:00.000Z", - errorMessage: "sha512 checksum mismatch", - }); - expect(secondAutomaticResult).toEqual(firstAutomaticResult); - - runtime.nextCheck(null); - const retryResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "manual", - }); - - expect(runtime.checkCount).toBe(2); - expect(retryResult).toEqual({ - hasUpdate: false, - readyToInstall: false, - currentVersion: "1.2.3", - latestVersion: "1.2.3", - body: null, - date: null, - errorMessage: null, - }); - }); - - it("keeps runtime update errors visible after a manual retry fails", async () => { - const { runtime, service } = createService(); - runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); - - await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "manual", - }); - runtime.failRuntime(new Error("sha512 checksum mismatch")); - - runtime.failNextCheck(new Error("network down")); - const retryResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "manual", - }); - const automaticResult = await service.checkForAppUpdate({ - currentVersion: "1.2.3", - releaseChannel: "stable", - intent: "automatic", - }); - - expect(retryResult.errorMessage).toBe("network down"); - expect(automaticResult).toEqual({ - hasUpdate: true, - readyToInstall: false, - currentVersion: "1.2.3", - latestVersion: "1.2.4", - body: null, - date: "2026-04-28T00:00:00.000Z", - errorMessage: "sha512 checksum mismatch", - }); - }); }); diff --git a/packages/desktop/src/features/app-update-service.ts b/packages/desktop/src/features/app-update-service.ts index f62dd8167..c6ecb9afd 100644 --- a/packages/desktop/src/features/app-update-service.ts +++ b/packages/desktop/src/features/app-update-service.ts @@ -62,6 +62,10 @@ export interface AppUpdateService { }, onBeforeQuit?: () => Promise, ): Promise; + installUpdateOnQuit(input: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + }): Promise; } export interface AppUpdateServiceDeps { @@ -112,10 +116,7 @@ function getErrorMessage(error: unknown): string { export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService { let cachedUpdateInfo: RuntimeUpdateInfo | null = null; let downloadedUpdateVersion: string | null = null; - let downloading = false; let configuredReleaseChannel: AppReleaseChannel | null = null; - let runtimeErrorMessage: string | null = null; - let inFlightUpdateCheckCount = 0; function isReadyToInstallVersion(version: string): boolean { return downloadedUpdateVersion === version; @@ -124,23 +125,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer function clearUpdateState(): void { cachedUpdateInfo = null; downloadedUpdateVersion = null; - downloading = false; - runtimeErrorMessage = null; - } - - function buildRuntimeErrorResult(currentVersion: string): AppUpdateCheckResult | null { - if (!runtimeErrorMessage) { - return null; - } - - const info = cachedUpdateInfo; - return buildCheckResult({ - currentVersion, - hasUpdate: info?.version !== undefined && info.version !== currentVersion, - readyToInstall: false, - info, - errorMessage: runtimeErrorMessage, - }); } function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void { @@ -166,23 +150,15 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer const alreadyReady = downloadedUpdateVersion === info.version; cachedUpdateInfo = info; downloadedUpdateVersion = alreadyReady ? info.version : null; - downloading = !alreadyReady; - runtimeErrorMessage = null; }, onUpdateDownloaded(info) { cachedUpdateInfo = info; downloadedUpdateVersion = info.version; - downloading = false; - runtimeErrorMessage = null; }, onUpdateNotAvailable() { clearUpdateState(); }, onError(error) { - downloading = false; - if (inFlightUpdateCheckCount === 0 || cachedUpdateInfo) { - runtimeErrorMessage = getErrorMessage(error); - } deps.reportRuntimeError?.(error); }, }); @@ -207,28 +183,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer configureRuntime(releaseChannel, intent); - const runtimeErrorResult = buildRuntimeErrorResult(currentVersion); - if (runtimeErrorResult && intent === "automatic") { - return runtimeErrorResult; - } - - const cachedVersion = cachedUpdateInfo?.version ?? null; - if ( - !runtimeErrorResult && - intent === "automatic" && - cachedVersion && - cachedVersion !== currentVersion - ) { - return buildCheckResult({ - currentVersion, - hasUpdate: true, - readyToInstall: isReadyToInstallVersion(cachedVersion), - info: cachedUpdateInfo, - }); - } - try { - inFlightUpdateCheckCount += 1; const result = await deps.runtime.checkForUpdates(); if (!result || !result.updateInfo || !result.isUpdateAvailable) { clearUpdateState(); @@ -245,8 +200,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer if (hasUpdate) { cachedUpdateInfo = info; - downloading = !isReadyToInstallVersion(latestVersion); - runtimeErrorMessage = null; return buildCheckResult({ currentVersion, hasUpdate: true, @@ -269,8 +222,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer readyToInstall: false, errorMessage: getErrorMessage(error), }); - } finally { - inFlightUpdateCheckCount -= 1; } } @@ -292,6 +243,26 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }; } + const check = await checkForAppUpdate({ + currentVersion, + releaseChannel, + intent: "manual", + }); + if (!check.hasUpdate) { + return { + installed: false, + version: currentVersion, + message: check.errorMessage ?? "No update available.", + }; + } + + return installCachedUpdate(currentVersion, onBeforeQuit); + } + + async function installCachedUpdate( + currentVersion: string, + onBeforeQuit?: () => Promise, + ): Promise { if (!cachedUpdateInfo) { return { installed: false, @@ -300,8 +271,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }; } - configureRuntime(releaseChannel, "manual"); - const readyVersion = cachedUpdateInfo.version; if (isReadyToInstallVersion(readyVersion)) { await performQuitAndInstall(deps.runtime, onBeforeQuit); @@ -312,20 +281,16 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }; } - if (downloading) { - return { - installed: false, - version: currentVersion, - message: "Update is still being prepared. Try again in a moment.", - }; - } - - downloading = true; - try { await deps.runtime.downloadUpdate(); + if (cachedUpdateInfo?.version !== readyVersion) { + return { + installed: false, + version: currentVersion, + message: "A newer update was found and will be installed later.", + }; + } downloadedUpdateVersion = readyVersion; - downloading = false; await performQuitAndInstall(deps.runtime, onBeforeQuit); return { @@ -334,7 +299,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer message: "Update downloaded. The app will restart shortly.", }; } catch (error) { - downloading = false; const message = error instanceof Error ? error.message : String(error); deps.reportInstallError?.(message); return { @@ -345,8 +309,33 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer } } + async function installUpdateOnQuit({ + currentVersion, + releaseChannel, + }: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + }): Promise { + if (!deps.isPackaged() || !downloadedUpdateVersion) { + return false; + } + + const check = await checkForAppUpdate({ + currentVersion, + releaseChannel, + intent: "automatic", + }); + if (!check.hasUpdate) { + return false; + } + + const result = await installCachedUpdate(currentVersion); + return result.installed; + } + return { checkForAppUpdate, downloadAndInstallUpdate, + installUpdateOnQuit, }; } diff --git a/packages/desktop/src/features/auto-updater.test.ts b/packages/desktop/src/features/auto-updater.test.ts index 4de12d87d..f810b8da3 100644 --- a/packages/desktop/src/features/auto-updater.test.ts +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -19,18 +19,8 @@ import { resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout, - shouldAutoInstallOnQuit, } from "./auto-updater"; -describe("shouldAutoInstallOnQuit", () => { - it("auto-installs on quit everywhere except Linux AppImage", () => { - expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: true })).toBe(false); - expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: false })).toBe(true); - expect(shouldAutoInstallOnQuit({ platform: "darwin", isAppImage: false })).toBe(true); - expect(shouldAutoInstallOnQuit({ platform: "win32", isAppImage: false })).toBe(true); - }); -}); - describe("shouldAdmitToRollout", () => { it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => { expect( diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index 876114585..39697bef8 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -76,31 +76,16 @@ export function getStagingUserId(): Promise { return cachedStagingUserIdPromise; } -// AppImages have no install step. electron-updater "installs" by unlinking the -// running file and mv-ing the downloaded one into place; on app quit it does this -// via a *blocking* execFileSync(newAppImage, { APPIMAGE_EXIT_AFTER_INSTALL: "true" }). -// That env var is only honored by AppImageLauncher, so without it the freshly -// launched process boots the full app and never exits — the quit hangs forever, -// with the old binary already deleted. We therefore install AppImages only on -// explicit quitAndInstall (the "Update now" button), which takes the non-blocking -// spawn path. Every other target keeps auto-install-on-quit, which works there. -export function shouldAutoInstallOnQuit(input: { - platform: NodeJS.Platform; - isAppImage: boolean; -}): boolean { - return !(input.platform === "linux" && input.isAppImage); -} - class ElectronAppUpdateRuntime implements AppUpdateRuntime { private configured = false; configure(input: AppUpdateRuntimeConfiguration): void { autoUpdater.autoDownload = true; autoUpdater.autoRunAppAfterInstall = true; - autoUpdater.autoInstallOnAppQuit = shouldAutoInstallOnQuit({ - platform: process.platform, - isAppImage: Boolean(process.env.APPIMAGE), - }); + // Paseo revalidates the current manifest before explicitly installing on quit. + // Electron's built-in handler would install an older download without checking + // whether a newer release has superseded it. + autoUpdater.autoInstallOnAppQuit = false; autoUpdater.allowPrerelease = input.releaseChannel === "beta"; autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest"; autoUpdater.allowDowngrade = false; @@ -194,3 +179,13 @@ export async function downloadAndInstallUpdate( onBeforeQuit, ); } + +export async function installAppUpdateOnQuit({ + currentVersion, + releaseChannel, +}: { + currentVersion: string; + releaseChannel: AppReleaseChannel; +}): Promise { + return appUpdateService.installUpdateOnQuit({ currentVersion, releaseChannel }); +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 494a78496..5f040c53b 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -85,6 +85,7 @@ import { import { runDesktopStartup } from "./desktop-startup.js"; import { autoUpdateInstalledSkills } from "./integrations/skills/index.js"; import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js"; +import { installAppUpdateOnQuit } from "./features/auto-updater.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; @@ -998,9 +999,19 @@ app.on( stopDaemon: () => stopDesktopDaemonViaCli("quit"), showShutdownFeedback: showDaemonShutdownDialog, }), + installAppUpdateOnQuit: async () => { + const settings = await getDesktopSettingsStore().get(); + return installAppUpdateOnQuit({ + currentVersion: app.getVersion(), + releaseChannel: settings.releaseChannel, + }); + }, onStopError: (error) => { log.error("[desktop daemon] failed to stop managed daemon on quit", error); }, + onUpdateError: (error) => { + log.error("[auto-updater] failed to validate downloaded update on quit", error); + }, }), );