diff --git a/docs/release.md b/docs/release.md index 90dd263e3..8252898ca 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 for up to five seconds 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 revalidation times out, the app exits without installing the cached update. 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..1d636bd95 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.test.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { - createBeforeQuitHandler, + createQuitLifecycle, shouldStopDesktopManagedDaemonOnQuit, stopDesktopManagedDaemonOnQuitIfNeeded, } from "./quit-lifecycle"; @@ -16,6 +16,18 @@ const SETTINGS_STOP_ON_QUIT = { }, }; +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function waitForQuitLifecycle(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + describe("quit-lifecycle", () => { it("only stops when keepRunningAfterQuit is explicitly disabled", () => { expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true); @@ -23,96 +35,238 @@ 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 () => { - 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(); + it("revalidates updates after daemon shutdown before exiting", async () => { + const stopDecision = deferred(); + const updateDecision = deferred(); + const events: string[] = []; - const handleBeforeQuit = createBeforeQuitHandler({ - app, - closeTransportSessions, - stopDesktopManagedDaemonIfNeeded: vi.fn( - () => - new Promise((resolve) => { - resolveStopDecision = () => resolve(false); - }), - ), - onStopError, + const quitLifecycle = createQuitLifecycle({ + app: { + exit: (code) => { + events.push(`exit:${code}`); + }, + }, + closeTransportSessions: () => { + events.push("close-transports"); + }, + stopDesktopManagedDaemonIfNeeded: () => stopDecision.promise, + installAppUpdateOnQuit: () => updateDecision.promise, + createUpdateDeadlineSignal: () => new AbortController().signal, + onStopError: () => { + events.push("stop-error"); + }, + onUpdateError: () => { + events.push("update-error"); + }, }); - handleBeforeQuit({ preventDefault }); + quitLifecycle.handleBeforeQuit({ + preventDefault: () => { + events.push("prevent-default"); + }, + }); - expect(preventDefault).toHaveBeenCalledTimes(1); - expect(closeTransportSessions).toHaveBeenCalledTimes(1); - expect(app.exit).not.toHaveBeenCalled(); - expect(resolveStopDecision).not.toBeNull(); + expect(events).toEqual(["close-transports", "prevent-default"]); - resolveStopDecision?.(); - await Promise.resolve(); - await Promise.resolve(); + events.push("daemon-stopped"); + stopDecision.resolve(false); + await waitForQuitLifecycle(); - expect(app.exit).toHaveBeenCalledWith(0); - expect(onStopError).not.toHaveBeenCalled(); + expect(events).toEqual(["close-transports", "prevent-default", "daemon-stopped"]); - handleBeforeQuit({ preventDefault: secondPreventDefault }); + events.push("update-checked"); + updateDecision.resolve(false); + await waitForQuitLifecycle(); - 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", + ]); + + quitLifecycle.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 quitLifecycle = createQuitLifecycle({ + app: { exit: (code) => exits.push(code) }, + closeTransportSessions: () => {}, + stopDesktopManagedDaemonIfNeeded: async () => false, + installAppUpdateOnQuit: async () => true, + createUpdateDeadlineSignal: () => new AbortController().signal, + onStopError: () => {}, + onUpdateError: () => {}, + }); + + quitLifecycle.handleBeforeQuit({ preventDefault: () => {} }); + await waitForQuitLifecycle(); + quitLifecycle.handleBeforeQuitForUpdate(); + await waitForQuitLifecycle(); + + expect(exits).toEqual([]); + }); + + it("recognizes a repeated quit as updater handoff", async () => { + const exits: number[] = []; + let preventedQuitCount = 0; + const quitLifecycle = createQuitLifecycle({ + app: { exit: (code) => exits.push(code) }, + closeTransportSessions: () => {}, + stopDesktopManagedDaemonIfNeeded: async () => false, + installAppUpdateOnQuit: async () => true, + createUpdateDeadlineSignal: () => new AbortController().signal, + onStopError: () => {}, + onUpdateError: () => {}, + }); + + quitLifecycle.handleBeforeQuit({ preventDefault: () => preventedQuitCount++ }); + await waitForQuitLifecycle(); + quitLifecycle.handleBeforeQuit({ preventDefault: () => preventedQuitCount++ }); + await waitForQuitLifecycle(); + + expect(preventedQuitCount).toBe(1); + expect(exits).toEqual([]); + }); + + it("exits when the updater does not take ownership before its deadline", async () => { + const revalidationDeadline = new AbortController(); + const handoffDeadline = new AbortController(); + let deadlineCount = 0; + const exits: number[] = []; + const quitLifecycle = createQuitLifecycle({ + app: { exit: (code) => exits.push(code) }, + closeTransportSessions: () => {}, + stopDesktopManagedDaemonIfNeeded: async () => false, + installAppUpdateOnQuit: async () => true, + createUpdateDeadlineSignal: () => + deadlineCount++ === 0 ? revalidationDeadline.signal : handoffDeadline.signal, + onStopError: () => {}, + onUpdateError: () => {}, + }); + + quitLifecycle.handleBeforeQuit({ preventDefault: () => {} }); + await waitForQuitLifecycle(); + handoffDeadline.abort(); + await waitForQuitLifecycle(); + + expect(exits).toEqual([0]); + }); + + it("does not intercept a quit started by a manual update", () => { + const events: string[] = []; + const quitLifecycle = createQuitLifecycle({ + app: { exit: (code) => events.push(`exit:${code}`) }, + closeTransportSessions: () => events.push("close-transports"), + stopDesktopManagedDaemonIfNeeded: async () => { + events.push("stop-daemon"); + return false; + }, + installAppUpdateOnQuit: async () => { + events.push("revalidate-update"); + return false; + }, + createUpdateDeadlineSignal: () => new AbortController().signal, + onStopError: () => events.push("stop-error"), + onUpdateError: () => events.push("update-error"), + }); + + quitLifecycle.handleBeforeQuitForUpdate(); + quitLifecycle.handleBeforeQuit({ + preventDefault: () => events.push("prevent-default"), + }); + + expect(events).toEqual(["close-transports"]); + }); + + it("exits when update revalidation reaches its deadline", async () => { + const deadline = new AbortController(); + const updateDecision = deferred(); + const exits: number[] = []; + const quitLifecycle = createQuitLifecycle({ + app: { exit: (code) => exits.push(code) }, + closeTransportSessions: () => {}, + stopDesktopManagedDaemonIfNeeded: async () => false, + installAppUpdateOnQuit: () => updateDecision.promise, + createUpdateDeadlineSignal: () => deadline.signal, + onStopError: () => {}, + onUpdateError: () => {}, + }); + + quitLifecycle.handleBeforeQuit({ preventDefault: () => {} }); + await waitForQuitLifecycle(); + deadline.abort(); + await waitForQuitLifecycle(); + + expect(exits).toEqual([0]); + + updateDecision.resolve(true); + await waitForQuitLifecycle(); + expect(exits).toEqual([0]); }); }); diff --git a/packages/desktop/src/daemon/quit-lifecycle.ts b/packages/desktop/src/daemon/quit-lifecycle.ts index 879f59778..1998985f5 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.ts @@ -14,6 +14,16 @@ interface BeforeQuitApp { exit(code: number): void; } +interface QuitLifecycle { + handleBeforeQuit(event: BeforeQuitEvent): void; + handleBeforeQuitForUpdate(): void; +} + +interface DeferredUpdateQuit { + promise: Promise; + resolve(): void; +} + export interface StopOnQuitDeps { settingsStore: Pick; isDesktopManagedDaemonRunning: () => boolean; @@ -42,36 +52,95 @@ export async function stopDesktopManagedDaemonOnQuitIfNeeded( return true; } -export function createBeforeQuitHandler({ +function waitForUpdateDeadline(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(false); + } + + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(false), { once: true }); + }); +} + +function createDeferredUpdateQuit(): DeferredUpdateQuit { + let resolvePromise!: (started: boolean) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: () => resolvePromise(true) }; +} + +export function createQuitLifecycle({ app, closeTransportSessions, stopDesktopManagedDaemonIfNeeded, + installAppUpdateOnQuit, + createUpdateDeadlineSignal, onStopError, + onUpdateError, }: { app: BeforeQuitApp; closeTransportSessions: () => void; stopDesktopManagedDaemonIfNeeded: () => Promise; + installAppUpdateOnQuit: (signal: AbortSignal) => Promise; + createUpdateDeadlineSignal: () => AbortSignal; onStopError: (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(). + onUpdateError: (error: unknown) => void; +}): QuitLifecycle { + // 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; + let quittingForUpdate = false; + const updateQuit = createDeferredUpdateQuit(); - return (event) => { + function handleBeforeQuit(event: BeforeQuitEvent): void { closeTransportSessions(); - if (quitting) return; + if (quittingForUpdate) return; + if (quitting) { + // MacUpdater's no-relaunch path calls app.quit() without emitting + // before-quit-for-update. A second quit is equivalent handoff evidence. + updateQuit.resolve(); + return; + } quitting = true; event.preventDefault(); - void stopDesktopManagedDaemonIfNeeded() - .catch((error) => { + void (async () => { + try { + await stopDesktopManagedDaemonIfNeeded(); + } catch (error) { onStopError(error); - }) - .finally(() => { - app.exit(0); + } + + const signal = createUpdateDeadlineSignal(); + const updateInstallation = installAppUpdateOnQuit(signal).catch((error) => { + onUpdateError(error); + return false; }); + const installingUpdate = await Promise.race([ + updateInstallation, + waitForUpdateDeadline(signal), + ]); + if (installingUpdate) { + const handoffStarted = await Promise.race([ + updateQuit.promise, + waitForUpdateDeadline(createUpdateDeadlineSignal()), + ]); + if (handoffStarted) { + return; + } + } + + app.exit(0); + })(); + } + + return { + handleBeforeQuit, + handleBeforeQuitForUpdate() { + quittingForUpdate = true; + updateQuit.resolve(); + }, }; } diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts index f243a7c28..1e8154602 100644 --- a/packages/desktop/src/features/app-update-service.test.ts +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -17,7 +17,19 @@ 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; + private activeDownload: { + info: RuntimeUpdateInfo; + promise: Promise; + resolve(): void; + reject(error: Error): void; + } | null = null; checkCount = 0; + downloadCallCount = 0; + downloadedVersions: string[] = []; + installedVersions: string[] = []; + installModes: Array<{ isSilent: boolean; isForceRunAfter: boolean }> = []; configure(input: AppUpdateRuntimeConfiguration): void { this.configuration = input; @@ -59,9 +71,42 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } finishUpdateDownload(info: RuntimeUpdateInfo): void { + this.downloadedUpdate = info; + this.downloadedVersions.push(info.version); this.configuration?.onUpdateDownloaded(info); } + beginUpdateDownload(info: RuntimeUpdateInfo): { + resolve(): void; + reject(error: Error): void; + } { + this.downloadableUpdate = info; + this.prepareUpdate(info); + let resolvePromise!: () => void; + let rejectPromise!: (error: Error) => void; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + void promise.catch(() => undefined); + const activeDownload = { + info, + promise, + resolve: () => { + this.finishUpdateDownload(info); + this.activeDownload = null; + resolvePromise(); + }, + reject: (error: Error) => { + this.configuration?.onError(error); + this.activeDownload = null; + rejectPromise(error); + }, + }; + this.activeDownload = activeDownload; + return { resolve: activeDownload.resolve, reject: activeDownload.reject }; + } + async checkForUpdates(): Promise<{ isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo; @@ -80,12 +125,27 @@ 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 { + this.downloadCallCount += 1; + if (this.activeDownload) { + return this.activeDownload.promise; + } + if (this.downloadableUpdate) { + this.finishUpdateDownload(this.downloadableUpdate); + } + } - quitAndInstall(): void {} + quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + if (this.downloadedUpdate) { + this.installedVersions.push(this.downloadedUpdate.version); + this.installModes.push({ isSilent, isForceRunAfter }); + } + } } function createService(input?: { now?: () => number; bucket?: () => Promise }) { @@ -148,6 +208,33 @@ describe("app update service", () => { }); }); + it("waits for an automatic poll before starting a manual rollout-bypassing check", async () => { + const { runtime, service } = createService(); + const automaticCheck = runtime.deferNextCheck(); + const automaticPending = service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + const manualPending = service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + await Promise.resolve(); + expect(runtime.checkCount).toBe(1); + + automaticCheck.resolve({ isUpdateAvailable: false, updateInfo: rolledOutUpdate }); + await automaticPending; + const manualResult = await manualPending; + + expect(runtime.checkCount).toBe(2); + expect(manualResult.hasUpdate).toBe(true); + expect(manualResult.latestVersion).toBe("1.2.4"); + }); + it("performs a fresh manual check when an update is already cached", async () => { const { runtime, service } = createService({ bucket: async () => 0 }); runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); @@ -179,6 +266,218 @@ 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", + signal: new AbortController().signal, + }); + + expect(installed).toBe(true); + expect(runtime.installedVersions).toEqual(["1.2.5"]); + expect(runtime.installModes).toEqual([{ isSilent: true, isForceRunAfter: false }]); + }); + + 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", + signal: new AbortController().signal, + }); + + expect(installed).toBe(false); + expect(runtime.installedVersions).toEqual([]); + }); + + it("does not install after quit-time revalidation expires", 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 deadline = new AbortController(); + deadline.abort(); + runtime.nextCheck({ + isUpdateAvailable: true, + updateInfo: { ...rolledOutUpdate, version: "1.2.5" }, + }); + const installed = await service.installUpdateOnQuit({ + currentVersion: "1.2.3", + releaseChannel: "stable", + signal: deadline.signal, + }); + + expect(installed).toBe(false); + expect(runtime.installedVersions).toEqual([]); + }); + + it("does not install an unvalidated download when the quit-time check fails", 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); + + runtime.failNextCheck(new Error("offline")); + const installed = await service.installUpdateOnQuit({ + currentVersion: "1.2.3", + releaseChannel: "stable", + signal: new AbortController().signal, + }); + + 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"]); + expect(runtime.installModes).toEqual([{ isSilent: false, isForceRunAfter: true }]); + }); + + it("waits for a stale active download before downloading and installing the rechecked version", 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", + }); + const staleDownload = runtime.beginUpdateDownload(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const installPending = service.downloadAndInstallUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + }); + await Promise.resolve(); + expect(runtime.installedVersions).toEqual([]); + + staleDownload.resolve(); + const result = await installPending; + + expect(result.installed).toBe(true); + expect(runtime.downloadedVersions).toEqual(["1.2.4", "1.2.5"]); + expect(runtime.installedVersions).toEqual(["1.2.5"]); + }); + + it("installs the rechecked version when the stale active download fails", 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", + }); + const staleDownload = runtime.beginUpdateDownload(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const installPending = service.downloadAndInstallUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(runtime.downloadCallCount).toBe(1); + + staleDownload.reject(new Error("old download failed")); + const result = await installPending; + + expect(result.installed).toBe(true); + expect(runtime.downloadedVersions).toEqual(["1.2.5"]); + 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,28 +621,26 @@ 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({ + it("surfaces preparation errors without blocking newer releases", 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", + intent: "manual", }); - 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({ + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + const failedPreparation = await service.checkForAppUpdate({ currentVersion: "1.2.3", releaseChannel: "stable", intent: "automatic", }); - expect(automaticResult).toEqual({ + expect(failedPreparation).toEqual({ hasUpdate: true, readyToInstall: false, currentVersion: "1.2.3", @@ -352,19 +649,9 @@ describe("app update service", () => { 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(); - 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 newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); const result = await service.checkForAppUpdate({ currentVersion: "1.2.3", releaseChannel: "stable", @@ -375,13 +662,43 @@ 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, }); }); + it("attributes a late preparation failure to the download that started it", 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.prepareUpdate(rolledOutUpdate); + + const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" }; + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + runtime.failRuntime(new Error("old download failed")); + + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate }); + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(result.latestVersion).toBe("1.2.5"); + expect(result.errorMessage).toBeNull(); + }); + it("performs a fresh manual check after an update preparation error", async () => { const { runtime, service } = createService(); runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); @@ -391,6 +708,7 @@ describe("app update service", () => { releaseChannel: "stable", intent: "manual", }); + runtime.prepareUpdate(rolledOutUpdate); runtime.failRuntime(new Error("sha512 checksum mismatch")); runtime.nextCheck({ @@ -445,91 +763,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..d5dc513ef 100644 --- a/packages/desktop/src/features/app-update-service.ts +++ b/packages/desktop/src/features/app-update-service.ts @@ -62,6 +62,11 @@ export interface AppUpdateService { }, onBeforeQuit?: () => Promise, ): Promise; + installUpdateOnQuit(input: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + signal: AbortSignal; + }): Promise; } export interface AppUpdateServiceDeps { @@ -96,10 +101,16 @@ function buildCheckResult(input: { async function performQuitAndInstall( runtime: AppUpdateRuntime, - onBeforeQuit?: () => Promise, + { + onBeforeQuit, + restart, + }: { + onBeforeQuit?: () => Promise; + restart: boolean; + }, ): Promise { if (onBeforeQuit) await onBeforeQuit(); - runtime.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); + runtime.quitAndInstall(/* isSilent */ !restart, /* isForceRunAfter */ restart); } function getErrorMessage(error: unknown): string { @@ -109,13 +120,21 @@ function getErrorMessage(error: unknown): string { return String(error); } +function buildDeferredInstallResult(currentVersion: string): AppUpdateInstallResult { + return { + installed: false, + version: currentVersion, + message: "Update validation timed out. The update will be installed later.", + }; +} + 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; + let preparationError: { version: string; message: string } | null = null; + let preparingUpdateVersion: string | null = null; + let checkQueue: Promise = Promise.resolve(); function isReadyToInstallVersion(version: string): boolean { return downloadedUpdateVersion === version; @@ -124,23 +143,8 @@ 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, - }); + preparationError = null; + preparingUpdateVersion = null; } function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void { @@ -166,28 +170,47 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer const alreadyReady = downloadedUpdateVersion === info.version; cachedUpdateInfo = info; downloadedUpdateVersion = alreadyReady ? info.version : null; - downloading = !alreadyReady; - runtimeErrorMessage = null; + if (!alreadyReady && preparingUpdateVersion === null) { + preparingUpdateVersion = info.version; + } }, onUpdateDownloaded(info) { - cachedUpdateInfo = info; + // A superseded download can finish after a newer manifest check. Keep + // the validated manifest as the install target in that case. + cachedUpdateInfo ??= info; downloadedUpdateVersion = info.version; - downloading = false; - runtimeErrorMessage = null; + if (preparingUpdateVersion === info.version) { + preparingUpdateVersion = null; + } + if (preparationError?.version === info.version) { + preparationError = null; + } }, onUpdateNotAvailable() { clearUpdateState(); }, onError(error) { - downloading = false; - if (inFlightUpdateCheckCount === 0 || cachedUpdateInfo) { - runtimeErrorMessage = getErrorMessage(error); + if (preparingUpdateVersion) { + preparationError = { + version: preparingUpdateVersion, + message: getErrorMessage(error), + }; + preparingUpdateVersion = null; } deps.reportRuntimeError?.(error); }, }); } + function runCheckExclusively(check: () => Promise): Promise { + const result = checkQueue.then(check, check); + checkQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + async function checkForAppUpdate({ currentVersion, releaseChannel, @@ -205,73 +228,56 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }); } - configureRuntime(releaseChannel, intent); + return runCheckExclusively(async () => { + configureRuntime(releaseChannel, intent); - const runtimeErrorResult = buildRuntimeErrorResult(currentVersion); - if (runtimeErrorResult && intent === "automatic") { - return runtimeErrorResult; - } + try { + const result = await deps.runtime.checkForUpdates(); + if (!result || !result.updateInfo || !result.isUpdateAvailable) { + clearUpdateState(); + return buildCheckResult({ + currentVersion, + hasUpdate: false, + readyToInstall: false, + }); + } - const cachedVersion = cachedUpdateInfo?.version ?? null; - if ( - !runtimeErrorResult && - intent === "automatic" && - cachedVersion && - cachedVersion !== currentVersion - ) { - return buildCheckResult({ - currentVersion, - hasUpdate: true, - readyToInstall: isReadyToInstallVersion(cachedVersion), - info: cachedUpdateInfo, - }); - } + const info = result.updateInfo; + const latestVersion = info.version; + const hasUpdate = latestVersion !== currentVersion; + + if (hasUpdate) { + cachedUpdateInfo = info; + const errorMessage = + preparationError?.version === latestVersion ? preparationError.message : null; + if (!errorMessage) { + preparationError = null; + } + return buildCheckResult({ + currentVersion, + hasUpdate: true, + readyToInstall: isReadyToInstallVersion(latestVersion), + info, + errorMessage, + }); + } - try { - inFlightUpdateCheckCount += 1; - const result = await deps.runtime.checkForUpdates(); - if (!result || !result.updateInfo || !result.isUpdateAvailable) { clearUpdateState(); return buildCheckResult({ currentVersion, hasUpdate: false, readyToInstall: false, }); - } - - const info = result.updateInfo; - const latestVersion = info.version; - const hasUpdate = latestVersion !== currentVersion; - - if (hasUpdate) { - cachedUpdateInfo = info; - downloading = !isReadyToInstallVersion(latestVersion); - runtimeErrorMessage = null; + } catch (error) { + deps.reportCheckError?.(error); return buildCheckResult({ currentVersion, - hasUpdate: true, - readyToInstall: isReadyToInstallVersion(latestVersion), - info, + hasUpdate: false, + readyToInstall: false, + errorMessage: getErrorMessage(error), }); } - - clearUpdateState(); - return buildCheckResult({ - currentVersion, - hasUpdate: false, - readyToInstall: false, - }); - } catch (error) { - deps.reportCheckError?.(error); - return buildCheckResult({ - currentVersion, - hasUpdate: false, - readyToInstall: false, - errorMessage: getErrorMessage(error), - }); - } finally { - inFlightUpdateCheckCount -= 1; - } + }); } async function downloadAndInstallUpdate( @@ -292,6 +298,69 @@ 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, restart: true }); + } + + async function ensureUpdateDownloaded( + readyVersion: string, + signal?: AbortSignal, + ): Promise<"ready" | "aborted" | "superseded"> { + while (!isReadyToInstallVersion(readyVersion)) { + if (signal?.aborted) return "aborted"; + if (cachedUpdateInfo?.version !== readyVersion) return "superseded"; + + const attemptedVersion: string = preparingUpdateVersion ?? readyVersion; + preparingUpdateVersion ??= readyVersion; + try { + await deps.runtime.downloadUpdate(); + } catch (error) { + if ( + attemptedVersion !== readyVersion && + cachedUpdateInfo?.version === readyVersion && + !signal?.aborted + ) { + continue; + } + throw error; + } + + // electron-updater can return an older, already-running download. Its + // event clears that version, then the next iteration starts the newly + // validated release instead of treating the stale artifact as ready. + if (attemptedVersion === readyVersion && !isReadyToInstallVersion(readyVersion)) { + downloadedUpdateVersion = readyVersion; + preparingUpdateVersion = null; + } + } + + return signal?.aborted ? "aborted" : "ready"; + } + + async function installCachedUpdate( + currentVersion: string, + { + onBeforeQuit, + signal, + restart, + }: { + onBeforeQuit?: () => Promise; + signal?: AbortSignal; + restart: boolean; + }, + ): Promise { if (!cachedUpdateInfo) { return { installed: false, @@ -300,11 +369,13 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }; } - configureRuntime(releaseChannel, "manual"); - const readyVersion = cachedUpdateInfo.version; + if (signal?.aborted) { + return buildDeferredInstallResult(currentVersion); + } + if (isReadyToInstallVersion(readyVersion)) { - await performQuitAndInstall(deps.runtime, onBeforeQuit); + await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart }); return { installed: true, version: readyVersion, @@ -312,21 +383,19 @@ 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(); - downloadedUpdateVersion = readyVersion; - downloading = false; - await performQuitAndInstall(deps.runtime, onBeforeQuit); + const preparation = await ensureUpdateDownloaded(readyVersion, signal); + if (preparation === "aborted") { + return buildDeferredInstallResult(currentVersion); + } + if (preparation === "superseded") { + return { + installed: false, + version: currentVersion, + message: "A newer update was found and will be installed later.", + }; + } + await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart }); return { installed: true, @@ -334,7 +403,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 +413,35 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer } } + async function installUpdateOnQuit({ + currentVersion, + releaseChannel, + signal, + }: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + signal: AbortSignal; + }): Promise { + if (!deps.isPackaged() || !downloadedUpdateVersion) { + return false; + } + + const check = await checkForAppUpdate({ + currentVersion, + releaseChannel, + intent: "automatic", + }); + if (signal.aborted || !check.hasUpdate) { + return false; + } + + const result = await installCachedUpdate(currentVersion, { signal, restart: false }); + 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..3c28e61c1 100644 --- a/packages/desktop/src/features/auto-updater.test.ts +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -19,15 +19,15 @@ import { resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout, - shouldAutoInstallOnQuit, + shouldInstallAppUpdateOnQuit, } 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("shouldInstallAppUpdateOnQuit", () => { + it("keeps Linux AppImage updates on the manual install path", () => { + expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: true })).toBe(false); + expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: false })).toBe(true); + expect(shouldInstallAppUpdateOnQuit({ platform: "darwin", isAppImage: false })).toBe(true); + expect(shouldInstallAppUpdateOnQuit({ platform: "win32", isAppImage: false })).toBe(true); }); }); diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index 876114585..c9b9a983b 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -76,18 +76,12 @@ 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: { +export function shouldInstallAppUpdateOnQuit(input: { platform: NodeJS.Platform; isAppImage: boolean; }): boolean { + // AppImage's no-relaunch install path blocks while launching the replacement + // binary, which can hang after the running file has already been replaced. return !(input.platform === "linux" && input.isAppImage); } @@ -97,10 +91,10 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { 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; @@ -143,6 +137,7 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { } quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + autoUpdater.autoRunAppAfterInstall = isForceRunAfter; autoUpdater.quitAndInstall(isSilent, isForceRunAfter); } } @@ -194,3 +189,24 @@ export async function downloadAndInstallUpdate( onBeforeQuit, ); } + +export async function installAppUpdateOnQuit({ + currentVersion, + releaseChannel, + signal, +}: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + signal: AbortSignal; +}): Promise { + if ( + !shouldInstallAppUpdateOnQuit({ + platform: process.platform, + isAppImage: Boolean(process.env.APPIMAGE), + }) + ) { + return false; + } + + return appUpdateService.installUpdateOnQuit({ currentVersion, releaseChannel, signal }); +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index a75eb7106..ef4e8a82e 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -12,6 +12,7 @@ import { existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { app, + autoUpdater as electronAutoUpdater, BrowserWindow, clipboard, Menu, @@ -81,19 +82,21 @@ import { stopDesktopDaemonViaCli, } from "./daemon/daemon-manager.js"; import { - createBeforeQuitHandler, + createQuitLifecycle, stopDesktopManagedDaemonOnQuitIfNeeded, } from "./daemon/quit-lifecycle.js"; import { runDesktopStartup } from "./desktop-startup.js"; import { autoUpdateInstalledSkills } from "./integrations/skills/index.js"; import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js"; import { BrowserKeyboard } from "./features/browser-keyboard/index.js"; +import { installAppUpdateOnQuit } from "./features/auto-updater.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; const PASEO_DEBUG = process.env.PASEO_DEBUG === "1"; const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1"; const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo"; +const UPDATE_QUIT_DEADLINE_MS = 5_000; const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests(); app.setName(APP_NAME); @@ -928,23 +931,36 @@ function showDaemonShutdownDialog(): void { } } -app.on( - "before-quit", - createBeforeQuitHandler({ - app, - closeTransportSessions: closeAllTransportSessions, - stopDesktopManagedDaemonIfNeeded: () => - stopDesktopManagedDaemonOnQuitIfNeeded({ - settingsStore: getDesktopSettingsStore(), - isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync, - stopDaemon: () => stopDesktopDaemonViaCli("quit"), - showShutdownFeedback: showDaemonShutdownDialog, - }), - onStopError: (error) => { - log.error("[desktop daemon] failed to stop managed daemon on quit", error); - }, - }), -); +const quitLifecycle = createQuitLifecycle({ + app, + closeTransportSessions: closeAllTransportSessions, + stopDesktopManagedDaemonIfNeeded: () => + stopDesktopManagedDaemonOnQuitIfNeeded({ + settingsStore: getDesktopSettingsStore(), + isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync, + stopDaemon: () => stopDesktopDaemonViaCli("quit"), + showShutdownFeedback: showDaemonShutdownDialog, + }), + installAppUpdateOnQuit: async (signal) => { + const settings = await getDesktopSettingsStore().get(); + return installAppUpdateOnQuit({ + currentVersion: app.getVersion(), + releaseChannel: settings.releaseChannel, + signal, + }); + }, + createUpdateDeadlineSignal: () => AbortSignal.timeout(UPDATE_QUIT_DEADLINE_MS), + 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); + }, +}); + +// electron-updater forwards this event through Electron's built-in autoUpdater. +electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate); +app.on("before-quit", quitLifecycle.handleBeforeQuit); app.on("window-all-closed", () => { if (process.platform !== "darwin") {