From eca0a5bf67be40ac43b84929c8e100d44574e5cf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 9 Jun 2026 15:47:30 +0700 Subject: [PATCH] Let manual update checks bypass rollout Automatic desktop update checks still respect rollout admission. Manual checks carry an explicit intent through the app and desktop updater service, and the up-to-date state now shows when the last check completed. --- docs/release.md | 4 +- .../updates/desktop-app-updater.test.ts | 41 +- .../desktop/updates/desktop-app-updater.ts | 24 +- .../src/desktop/updates/desktop-updates.ts | 8 +- .../fake-desktop-app-updater-port.ts | 11 +- .../desktop/updates/update-callout-source.tsx | 4 +- .../updates/use-desktop-app-updater.ts | 21 +- packages/app/src/screens/settings-screen.tsx | 2 +- packages/desktop/src/daemon/daemon-manager.ts | 8 + .../src/features/app-update-rollout.test.ts | 156 ++++++++ .../src/features/app-update-rollout.ts | 42 +++ .../src/features/app-update-service.test.ts | 114 ++++++ .../src/features/app-update-service.ts | 303 +++++++++++++++ packages/desktop/src/features/auto-updater.ts | 355 +++++------------- 14 files changed, 809 insertions(+), 284 deletions(-) create mode 100644 packages/desktop/src/features/app-update-rollout.test.ts create mode 100644 packages/desktop/src/features/app-update-rollout.ts create mode 100644 packages/desktop/src/features/app-update-service.test.ts create mode 100644 packages/desktop/src/features/app-update-service.ts diff --git a/docs/release.md b/docs/release.md index e74e8aa3f..ab4d62aff 100644 --- a/docs/release.md +++ b/docs/release.md @@ -89,7 +89,7 @@ Use the beta path when you need to: ## Staged rollout (stable channel) -Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately. +Stable desktop releases go out via a linear time-based rollout for automatic update checks: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Manual checks bypass the rollout so a user can install immediately when they click **Check**. Beta releases bypass the rollout entirely — beta users always receive updates immediately. The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`. @@ -173,7 +173,7 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1. { - it("forwards the requested release channel to the port", async () => { + it("forwards manual check intent and the requested release channel to the port", async () => { const { updater, port } = createUpdater(); port.nextCheckResult(buildFakeCheckResult()); await updater.checkForUpdates({ releaseChannel: "beta" }); - expect(port.recordedChecks).toEqual([{ releaseChannel: "beta" }]); + expect(port.recordedChecks).toEqual([{ releaseChannel: "beta", intent: "manual" }]); + }); + + it("forwards automatic check intent independently from silent UI state", async () => { + const { updater, port } = createUpdater(); + port.nextCheckResult(buildFakeCheckResult()); + + await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true }); + + expect(port.recordedChecks).toEqual([{ releaseChannel: "stable", intent: "automatic" }]); }); it("moves to 'checking' during a non-silent check", async () => { @@ -66,7 +75,11 @@ describe("desktop app updater — check", () => { expect(updater.getSnapshot().status).toBe("available"); const deferred = port.deferNextCheck(); - const pending = updater.checkForUpdates({ releaseChannel: "stable", silent: true }); + const pending = updater.checkForUpdates({ + releaseChannel: "stable", + intent: "automatic", + silent: true, + }); expect(updater.getSnapshot().status).toBe("available"); deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); @@ -128,7 +141,7 @@ describe("desktop app updater — check", () => { const statusBeforeSilent = updater.getSnapshot().status; port.failNextCheck(new Error("boom")); - await updater.checkForUpdates({ releaseChannel: "stable", silent: true }); + await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true }); expect(updater.getSnapshot().status).toBe(statusBeforeSilent); }); @@ -226,6 +239,20 @@ describe("desktop app updater — subscribe", () => { describe("formatStatusText", () => { const formatVersion = (version: string | null | undefined) => version ? `v${version.replace(/^v/i, "")}` : "\u2014"; + const formatLastCheckedAt = (timestamp: number) => `time-${timestamp}`; + + it("shows when an up-to-date check completed", () => { + expect( + formatStatusText({ + status: "up-to-date", + availableUpdate: null, + installMessage: null, + lastCheckedAt: 42, + formatVersion, + formatLastCheckedAt, + }), + ).toBe("Up to date. Last checked at time-42."); + }); it("uses the latest version in the 'available' message when present", () => { expect( @@ -233,7 +260,9 @@ describe("formatStatusText", () => { status: "available", availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }), installMessage: null, + lastCheckedAt: null, formatVersion, + formatLastCheckedAt, }), ).toBe("Update ready: v1.2.3"); }); @@ -244,7 +273,9 @@ describe("formatStatusText", () => { status: "available", availableUpdate: null, installMessage: null, + lastCheckedAt: null, formatVersion, + formatLastCheckedAt, }), ).toBe("An app update is ready to install."); }); @@ -255,7 +286,9 @@ describe("formatStatusText", () => { status: "installed", availableUpdate: null, installMessage: "Restart now", + lastCheckedAt: null, formatVersion, + formatLastCheckedAt, }), ).toBe("Restart now"); }); diff --git a/packages/app/src/desktop/updates/desktop-app-updater.ts b/packages/app/src/desktop/updates/desktop-app-updater.ts index 1bcb7b5f6..ad444d9f1 100644 --- a/packages/app/src/desktop/updates/desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/desktop-app-updater.ts @@ -1,5 +1,6 @@ import type { DesktopAppUpdateCheckResult, + DesktopAppUpdateCheckIntent, DesktopAppUpdateInstallResult, DesktopReleaseChannel, } from "@/desktop/updates/desktop-updates"; @@ -29,6 +30,7 @@ export interface DesktopAppUpdaterSnapshot { export interface DesktopAppUpdaterPort { checkDesktopAppUpdate(input: { releaseChannel: DesktopReleaseChannel; + intent: DesktopAppUpdateCheckIntent; }): Promise; installDesktopAppUpdate(input: { releaseChannel: DesktopReleaseChannel; @@ -52,6 +54,7 @@ export interface DesktopAppUpdater { subscribe(listener: () => void): () => void; checkForUpdates(options?: { releaseChannel: DesktopReleaseChannel; + intent?: DesktopAppUpdateCheckIntent; silent?: boolean; }): Promise; installUpdate(options: { @@ -102,9 +105,18 @@ export function formatStatusText(input: { status: DesktopAppUpdateStatus; availableUpdate: DesktopAppUpdateCheckResult | null; installMessage: string | null; + lastCheckedAt: number | null; formatVersion: (version: string | null | undefined) => string; + formatLastCheckedAt: (timestamp: number) => string; }): string { - const { status, availableUpdate, installMessage, formatVersion } = input; + const { + status, + availableUpdate, + installMessage, + lastCheckedAt, + formatVersion, + formatLastCheckedAt, + } = input; if (status === "checking") { return "Checking for app updates..."; @@ -115,7 +127,10 @@ export function formatStatusText(input: { } if (status === "up-to-date") { - return "App is up to date."; + if (lastCheckedAt != null) { + return `Up to date. Last checked at ${formatLastCheckedAt(lastCheckedAt)}.`; + } + return "Up to date."; } if (status === "pending") { @@ -155,12 +170,13 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp async function checkForUpdates(options?: { releaseChannel: DesktopReleaseChannel; + intent?: DesktopAppUpdateCheckIntent; silent?: boolean; }): Promise { if (!options) { return null; } - const { releaseChannel, silent = false } = options; + const { releaseChannel, intent = "manual", silent = false } = options; const requestVersion = state.requestVersion + 1; commit({ @@ -171,7 +187,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp }); try { - const result = await deps.port.checkDesktopAppUpdate({ releaseChannel }); + const result = await deps.port.checkDesktopAppUpdate({ releaseChannel, intent }); if (requestVersion !== state.requestVersion) { return result; } diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts index 966e5c72d..1e16fd095 100644 --- a/packages/app/src/desktop/updates/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -23,6 +23,7 @@ export interface DesktopRuntimeInfo { } export type DesktopReleaseChannel = "stable" | "beta"; +export type DesktopAppUpdateCheckIntent = "automatic" | "manual"; export interface LocalDaemonUpdateResult { exitCode: number; @@ -99,10 +100,15 @@ export async function getDesktopRuntimeInfo(): Promise { export async function checkDesktopAppUpdate({ releaseChannel, + intent, }: { releaseChannel: DesktopReleaseChannel; + intent: DesktopAppUpdateCheckIntent; }): Promise { - const result = await invokeDesktopCommand("check_app_update", { releaseChannel }); + const result = await invokeDesktopCommand("check_app_update", { + releaseChannel, + intent, + }); if (!isRecord(result)) { throw new Error("Unexpected response while checking desktop updates."); } diff --git a/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts b/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts index 7fa08b08b..420ddfbde 100644 --- a/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts +++ b/packages/app/src/desktop/updates/test-utils/fake-desktop-app-updater-port.ts @@ -1,12 +1,16 @@ import type { DesktopAppUpdateCheckResult, + DesktopAppUpdateCheckIntent, DesktopAppUpdateInstallResult, DesktopReleaseChannel, } from "@/desktop/updates/desktop-updates"; import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater"; export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort { - readonly recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }>; + readonly recordedChecks: Array<{ + releaseChannel: DesktopReleaseChannel; + intent: DesktopAppUpdateCheckIntent; + }>; readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>; nextCheckResult(result: DesktopAppUpdateCheckResult): void; deferNextCheck(): { @@ -53,7 +57,10 @@ function buildInstallResult( } export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort { - const recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }> = []; + const recordedChecks: Array<{ + releaseChannel: DesktopReleaseChannel; + intent: DesktopAppUpdateCheckIntent; + }> = []; const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = []; const checkOutcomes: CheckOutcome[] = []; const installOutcomes: InstallOutcome[] = []; diff --git a/packages/app/src/desktop/updates/update-callout-source.tsx b/packages/app/src/desktop/updates/update-callout-source.tsx index e3329a3dd..95d3dd0b2 100644 --- a/packages/app/src/desktop/updates/update-callout-source.tsx +++ b/packages/app/src/desktop/updates/update-callout-source.tsx @@ -62,10 +62,10 @@ export function UpdateCalloutSource() { useEffect(() => { if (!isDesktopApp) return; - void checkForUpdates({ silent: true }); + void checkForUpdates({ intent: "automatic", silent: true }); intervalRef.current = setInterval(() => { - void checkForUpdates({ silent: true }); + void checkForUpdates({ intent: "automatic", silent: true }); }, CHECK_INTERVAL_MS); return () => { diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.ts b/packages/app/src/desktop/updates/use-desktop-app-updater.ts index 5522213f1..16f8eb52f 100644 --- a/packages/app/src/desktop/updates/use-desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/use-desktop-app-updater.ts @@ -5,6 +5,7 @@ import { installDesktopAppUpdate, shouldShowDesktopUpdateSection, type DesktopAppUpdateCheckResult, + type DesktopAppUpdateCheckIntent, type DesktopAppUpdateInstallResult, } from "@/desktop/updates/desktop-updates"; import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; @@ -15,6 +16,7 @@ import { formatStatusText, type DesktopAppUpdateStatus, } from "@/desktop/updates/desktop-app-updater"; +import { formatMessageTimestamp } from "@/utils/time"; export type { DesktopAppUpdateStatus }; @@ -27,7 +29,10 @@ export interface UseDesktopAppUpdaterReturn { lastCheckedAt: number | null; isChecking: boolean; isInstalling: boolean; - checkForUpdates: (options?: { silent?: boolean }) => Promise; + checkForUpdates: (options?: { + intent?: DesktopAppUpdateCheckIntent; + silent?: boolean; + }) => Promise; installUpdate: () => Promise; } @@ -57,11 +62,15 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { ); const checkForUpdates = useCallback( - async (options: { silent?: boolean } = {}) => { + async (options: { intent?: DesktopAppUpdateCheckIntent; silent?: boolean } = {}) => { if (!isDesktopApp) { return null; } - return updater.checkForUpdates({ releaseChannel, silent: options.silent }); + return updater.checkForUpdates({ + releaseChannel, + intent: options.intent ?? "manual", + silent: options.silent, + }); }, [isDesktopApp, releaseChannel, updater], ); @@ -77,7 +86,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { if (!isDesktopApp) { return; } - void checkForUpdates({ silent: true }); + void checkForUpdates({ intent: "automatic", silent: true }); }, [checkForUpdates, isDesktopApp]); useEffect(() => { @@ -86,7 +95,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { } const intervalId = setInterval(() => { - void checkForUpdates({ silent: true }); + void checkForUpdates({ intent: "automatic", silent: true }); }, PENDING_RECHECK_MS); return () => { @@ -101,7 +110,9 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { status: snapshot.status, availableUpdate: snapshot.availableUpdate, installMessage: snapshot.installMessage, + lastCheckedAt: snapshot.lastCheckedAt, formatVersion: formatVersionWithPrefix, + formatLastCheckedAt: (timestamp) => formatMessageTimestamp(new Date(timestamp)), }), availableUpdate: snapshot.availableUpdate, errorMessage: snapshot.errorMessage, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 6d0ab7a2e..0c20713ec 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -523,7 +523,7 @@ function DesktopAppUpdateRow() { if (!isDesktopApp) { return undefined; } - void checkForUpdates({ silent: true }); + void checkForUpdates({ intent: "automatic", silent: true }); return undefined; }, [checkForUpdates, isDesktopApp]), ); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 3e21f5dfe..c429acd0a 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -15,6 +15,7 @@ import { import { checkForAppUpdate, downloadAndInstallUpdate, + type AppUpdateCheckIntent, type AppReleaseChannel, } from "../features/auto-updater.js"; import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js"; @@ -81,6 +82,12 @@ function parseReleaseChannel( return undefined; } +function parseAppUpdateCheckIntent( + args: Record | undefined, +): AppUpdateCheckIntent { + return args?.intent === "manual" ? "manual" : "automatic"; +} + // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- @@ -517,6 +524,7 @@ export function createDaemonCommandHandlers(): Record { diff --git a/packages/desktop/src/features/app-update-rollout.test.ts b/packages/desktop/src/features/app-update-rollout.test.ts new file mode 100644 index 000000000..66160bd19 --- /dev/null +++ b/packages/desktop/src/features/app-update-rollout.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import { bucketFromStagingUserId, shouldAdmitAppUpdate } from "./app-update-rollout"; + +describe("shouldAdmitAppUpdate", () => { + it("keeps automatic stable updates behind the rollout window", () => { + expect( + shouldAdmitAppUpdate({ + channel: "stable", + intent: "automatic", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T12:00:00.000Z"), + bucket: 0.51, + }), + ).toBe(false); + }); + + it("lets manual stable checks bypass rollout admission", () => { + expect( + shouldAdmitAppUpdate({ + channel: "stable", + intent: "manual", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T12:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + }); + + it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => { + expect( + shouldAdmitAppUpdate({ + channel: "beta", + intent: "automatic", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T01:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + expect( + shouldAdmitAppUpdate({ + channel: "stable", + intent: "automatic", + rolloutHours: undefined, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T01:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + expect( + shouldAdmitAppUpdate({ + channel: "stable", + intent: "automatic", + rolloutHours: 0, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T01:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + expect( + shouldAdmitAppUpdate({ + channel: "stable", + intent: "automatic", + rolloutHours: 24, + releaseDate: undefined, + now: Date.parse("2026-04-28T01:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + }); + + it("blocks future automatic releases and admits the same release manually", () => { + const input = { + channel: "stable" as const, + rolloutHours: 24, + releaseDate: "2026-04-28T02:00:00.000Z", + now: Date.parse("2026-04-28T01:00:00.000Z"), + bucket: 0, + }; + + expect(shouldAdmitAppUpdate({ ...input, intent: "automatic" })).toBe(false); + expect(shouldAdmitAppUpdate({ ...input, intent: "manual" })).toBe(true); + }); + + it("blocks the bucket-zero client at exact release time, admits as soon as time advances", () => { + expect( + shouldAdmitAppUpdate({ + channel: "stable", + intent: "automatic", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T00:00:00.000Z"), + bucket: 0, + }), + ).toBe(false); + expect( + shouldAdmitAppUpdate({ + channel: "stable", + intent: "automatic", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-28T00:00:00.001Z"), + bucket: 0, + }), + ).toBe(true); + }); + + it("admits the highest-bucket automatic client at and past the rollout end", () => { + const maxBucket = (0x100000000 - 1) / 0x100000000; + expect( + shouldAdmitAppUpdate({ + channel: "stable", + intent: "automatic", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2026-04-29T00:00:00.000Z"), + bucket: maxBucket, + }), + ).toBe(true); + expect( + shouldAdmitAppUpdate({ + channel: "stable", + intent: "automatic", + rolloutHours: 24, + releaseDate: "2026-04-28T00:00:00.000Z", + now: Date.parse("2027-04-28T00:00:00.000Z"), + bucket: maxBucket, + }), + ).toBe(true); + }); + + it("admits when releaseDate is unparseable", () => { + expect( + shouldAdmitAppUpdate({ + channel: "stable", + intent: "automatic", + rolloutHours: 24, + releaseDate: "not a date", + now: Date.parse("2026-04-28T12:00:00.000Z"), + bucket: 0.99, + }), + ).toBe(true); + }); + + it("maps the maximum 32-bit slot to a bucket strictly less than 1", () => { + const allOnes = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + const allZeros = "00000000-0000-0000-0000-000000000000"; + + expect(bucketFromStagingUserId(allOnes)).toBeLessThan(1); + expect(bucketFromStagingUserId(allOnes)).toBeGreaterThan(0.999); + expect(bucketFromStagingUserId(allZeros)).toBe(0); + }); +}); diff --git a/packages/desktop/src/features/app-update-rollout.ts b/packages/desktop/src/features/app-update-rollout.ts new file mode 100644 index 000000000..2b29f5ede --- /dev/null +++ b/packages/desktop/src/features/app-update-rollout.ts @@ -0,0 +1,42 @@ +import { UUID } from "builder-util-runtime"; +import { z } from "zod"; + +export type AppReleaseChannel = "stable" | "beta"; +export type AppUpdateCheckIntent = "automatic" | "manual"; + +export const rolloutManifestSchema = z.object({ + rolloutHours: z + .union([z.number(), z.string().transform(Number)]) + .pipe(z.number().finite().nonnegative()) + .optional() + .catch(undefined), + releaseDate: z.string().optional().catch(undefined), +}); + +export function shouldAdmitAppUpdate(args: { + channel: AppReleaseChannel; + intent: AppUpdateCheckIntent; + rolloutHours: number | undefined; + releaseDate: string | undefined; + now: number; + bucket: number; +}): boolean { + if (args.intent === "manual") return true; + if (args.channel !== "stable") return true; + if (args.rolloutHours == null) return true; + if (args.rolloutHours === 0) return true; + if (!args.releaseDate) return true; + + const releaseTime = new Date(args.releaseDate).getTime(); + if (Number.isNaN(releaseTime)) return true; + + const ageHours = (args.now - releaseTime) / 3_600_000; + if (ageHours < 0) return false; + + const pct = Math.min(100, (ageHours / args.rolloutHours) * 100); + return args.bucket * 100 < pct; +} + +export function bucketFromStagingUserId(stagingUserId: string): number { + return UUID.parse(stagingUserId).readUInt32BE(12) / 0x100000000; +} diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts new file mode 100644 index 000000000..d5b2c3705 --- /dev/null +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + createAppUpdateService, + type AppUpdateRuntime, + type AppUpdateRuntimeConfiguration, + type RuntimeUpdateInfo, +} from "./app-update-service"; + +class FakeAppUpdateRuntime implements AppUpdateRuntime { + private checks: Array<{ isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null> = []; + private gate: ((info: RuntimeUpdateInfo) => boolean | Promise) | null = null; + + configure(input: AppUpdateRuntimeConfiguration): void { + this.gate = input.shouldAdmitUpdate; + } + + nextCheck(result: { isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null): void { + this.checks.push(result); + } + + async checkForUpdates(): Promise<{ + isUpdateAvailable: boolean; + updateInfo: RuntimeUpdateInfo; + } | null> { + const result = this.checks.shift() ?? null; + if (!result || !this.gate) return result; + const admitted = await this.gate(result.updateInfo); + return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted }; + } + + async downloadUpdate(): Promise {} + + quitAndInstall(): void {} +} + +function createService(input?: { now?: () => number; bucket?: () => Promise }) { + const runtime = new FakeAppUpdateRuntime(); + const service = createAppUpdateService({ + runtime, + isPackaged: () => true, + now: input?.now ?? (() => Date.parse("2026-04-28T12:00:00.000Z")), + bucket: input?.bucket ?? (async () => 0.99), + }); + return { runtime, service }; +} + +const rolledOutUpdate = { + version: "1.2.4", + releaseDate: "2026-04-28T00:00:00.000Z", + rolloutHours: 24, +}; + +describe("app update service", () => { + it("does not expose automatic stable updates before the user is admitted to rollout", async () => { + const { runtime, service } = createService(); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "automatic", + }); + + expect(result).toEqual({ + hasUpdate: false, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.3", + body: null, + date: null, + }); + }); + + it("exposes manual stable updates even before the user is admitted to rollout", async () => { + const { runtime, service } = createService(); + runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate }); + + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + expect(result).toEqual({ + hasUpdate: true, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.4", + body: null, + date: "2026-04-28T00:00:00.000Z", + }); + }); + + it("trusts the runtime availability decision before comparing versions", async () => { + const { runtime, service } = createService({ bucket: async () => 0 }); + runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate }); + + const result = await service.checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + expect(result).toEqual({ + hasUpdate: false, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.3", + body: null, + date: null, + }); + }); +}); diff --git a/packages/desktop/src/features/app-update-service.ts b/packages/desktop/src/features/app-update-service.ts new file mode 100644 index 000000000..555e23e96 --- /dev/null +++ b/packages/desktop/src/features/app-update-service.ts @@ -0,0 +1,303 @@ +import { + rolloutManifestSchema, + shouldAdmitAppUpdate, + type AppReleaseChannel, + type AppUpdateCheckIntent, +} from "./app-update-rollout.js"; + +export interface AppUpdateCheckResult { + hasUpdate: boolean; + readyToInstall: boolean; + currentVersion: string; + latestVersion: string; + body: string | null; + date: string | null; +} + +export interface AppUpdateInstallResult { + installed: boolean; + version: string | null; + message: string; +} + +export interface RuntimeUpdateInfo { + version: string; + releaseNotes?: unknown; + releaseDate?: unknown; + rolloutHours?: unknown; +} + +export interface RuntimeUpdateCheckResult { + isUpdateAvailable: boolean; + updateInfo: RuntimeUpdateInfo; +} + +export interface AppUpdateRuntimeConfiguration { + releaseChannel: AppReleaseChannel; + shouldAdmitUpdate(info: RuntimeUpdateInfo): boolean | Promise; + onUpdateAvailable(info: RuntimeUpdateInfo): void; + onUpdateDownloaded(info: RuntimeUpdateInfo): void; + onUpdateNotAvailable(): void; + onError(error: unknown): void; +} + +export interface AppUpdateRuntime { + configure(input: AppUpdateRuntimeConfiguration): void; + checkForUpdates(): Promise; + downloadUpdate(): Promise; + quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void; +} + +export interface AppUpdateService { + checkForAppUpdate(input: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + intent: AppUpdateCheckIntent; + }): Promise; + downloadAndInstallUpdate( + input: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + }, + onBeforeQuit?: () => Promise, + ): Promise; +} + +export interface AppUpdateServiceDeps { + runtime: AppUpdateRuntime; + isPackaged(): boolean; + now(): number; + bucket(): Promise; + reportCheckError?(error: unknown): void; + reportRuntimeError?(error: unknown): void; + reportInstallError?(message: string): void; +} + +function buildCheckResult(input: { + currentVersion: string; + hasUpdate: boolean; + readyToInstall: boolean; + info?: RuntimeUpdateInfo | null; +}): AppUpdateCheckResult { + const { currentVersion, hasUpdate, readyToInstall, info } = input; + + return { + hasUpdate, + readyToInstall, + currentVersion, + latestVersion: info?.version ?? currentVersion, + body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null, + date: typeof info?.releaseDate === "string" ? info.releaseDate : null, + }; +} + +async function performQuitAndInstall( + runtime: AppUpdateRuntime, + onBeforeQuit?: () => Promise, +): Promise { + if (onBeforeQuit) await onBeforeQuit(); + runtime.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); +} + +export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService { + let cachedUpdateInfo: RuntimeUpdateInfo | null = null; + let downloadedUpdateVersion: string | null = null; + let downloading = false; + let configuredReleaseChannel: AppReleaseChannel | null = null; + + function isReadyToInstallVersion(version: string): boolean { + return downloadedUpdateVersion === version; + } + + function clearUpdateState(): void { + cachedUpdateInfo = null; + downloadedUpdateVersion = null; + downloading = false; + } + + function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void { + if (configuredReleaseChannel !== releaseChannel) { + clearUpdateState(); + configuredReleaseChannel = releaseChannel; + } + + deps.runtime.configure({ + releaseChannel, + shouldAdmitUpdate: async (info) => { + const parsed = rolloutManifestSchema.parse(info); + return shouldAdmitAppUpdate({ + channel: releaseChannel, + intent, + rolloutHours: parsed.rolloutHours, + releaseDate: parsed.releaseDate, + now: deps.now(), + bucket: await deps.bucket(), + }); + }, + onUpdateAvailable(info) { + cachedUpdateInfo = info; + downloadedUpdateVersion = null; + downloading = true; + }, + onUpdateDownloaded(info) { + cachedUpdateInfo = info; + downloadedUpdateVersion = info.version; + downloading = false; + }, + onUpdateNotAvailable() { + clearUpdateState(); + }, + onError(error) { + downloading = false; + deps.reportRuntimeError?.(error); + }, + }); + } + + async function checkForAppUpdate({ + currentVersion, + releaseChannel, + intent, + }: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + intent: AppUpdateCheckIntent; + }): Promise { + if (!deps.isPackaged()) { + return buildCheckResult({ + currentVersion, + hasUpdate: false, + readyToInstall: false, + }); + } + + configureRuntime(releaseChannel, intent); + + const cachedVersion = cachedUpdateInfo?.version ?? null; + if (cachedVersion && cachedVersion !== currentVersion) { + return buildCheckResult({ + currentVersion, + hasUpdate: true, + readyToInstall: isReadyToInstallVersion(cachedVersion), + info: cachedUpdateInfo, + }); + } + + try { + 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); + return buildCheckResult({ + currentVersion, + hasUpdate: true, + readyToInstall: isReadyToInstallVersion(latestVersion), + info, + }); + } + + clearUpdateState(); + return buildCheckResult({ + currentVersion, + hasUpdate: false, + readyToInstall: false, + }); + } catch (error) { + deps.reportCheckError?.(error); + return buildCheckResult({ + currentVersion, + hasUpdate: false, + readyToInstall: false, + }); + } + } + + async function downloadAndInstallUpdate( + { + currentVersion, + releaseChannel, + }: { + currentVersion: string; + releaseChannel: AppReleaseChannel; + }, + onBeforeQuit?: () => Promise, + ): Promise { + if (!deps.isPackaged()) { + return { + installed: false, + version: currentVersion, + message: "Auto-update is not available in development mode.", + }; + } + + if (!cachedUpdateInfo) { + return { + installed: false, + version: currentVersion, + message: "No update available. Check for updates first.", + }; + } + + configureRuntime(releaseChannel, "manual"); + + const readyVersion = cachedUpdateInfo.version; + if (isReadyToInstallVersion(readyVersion)) { + await performQuitAndInstall(deps.runtime, onBeforeQuit); + return { + installed: true, + version: readyVersion, + message: "Update downloaded. The app will restart shortly.", + }; + } + + 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); + + return { + installed: true, + version: readyVersion, + 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 { + installed: false, + version: currentVersion, + message: `Update failed: ${message}`, + }; + } + } + + return { + checkForAppUpdate, + downloadAndInstallUpdate, + }; +} diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index 0547a9642..92ad1b213 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -3,48 +3,34 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { app } from "electron"; import { UUID } from "builder-util-runtime"; -import { autoUpdater, type UpdateInfo } from "electron-updater"; -import { z } from "zod"; +import { autoUpdater } from "electron-updater"; +import { + createAppUpdateService, + type AppUpdateCheckResult, + type AppUpdateInstallResult, + type AppUpdateRuntime, + type AppUpdateRuntimeConfiguration, + type RuntimeUpdateCheckResult, + type RuntimeUpdateInfo, +} from "./app-update-service.js"; +import { + bucketFromStagingUserId, + rolloutManifestSchema, + shouldAdmitAppUpdate, + type AppReleaseChannel, + type AppUpdateCheckIntent, +} from "./app-update-rollout.js"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- +export { + bucketFromStagingUserId, + rolloutManifestSchema, + shouldAdmitAppUpdate, + type AppReleaseChannel, + type AppUpdateCheckIntent, + type AppUpdateCheckResult, + type AppUpdateInstallResult, +}; -export interface AppUpdateCheckResult { - hasUpdate: boolean; - readyToInstall: boolean; - currentVersion: string; - latestVersion: string; - body: string | null; - date: string | null; -} - -export interface AppUpdateInstallResult { - installed: boolean; - version: string | null; - message: string; -} - -export type AppReleaseChannel = "stable" | "beta"; - -export const rolloutManifestSchema = z.object({ - rolloutHours: z - .union([z.number(), z.string().transform(Number)]) - .pipe(z.number().finite().nonnegative()) - .optional() - .catch(undefined), - releaseDate: z.string().optional().catch(undefined), -}); - -// --------------------------------------------------------------------------- -// State -// --------------------------------------------------------------------------- - -let cachedUpdateInfo: UpdateInfo | null = null; -let downloadedUpdateVersion: string | null = null; -let downloading = false; -let autoUpdaterConfigured = false; -let configuredReleaseChannel: AppReleaseChannel | null = null; let cachedStagingUserIdPromise: Promise | null = null; export function shouldAdmitToRollout(args: { @@ -54,23 +40,7 @@ export function shouldAdmitToRollout(args: { now: number; bucket: number; }): boolean { - if (args.channel !== "stable") return true; - if (args.rolloutHours == null) return true; - if (args.rolloutHours === 0) return true; - if (!args.releaseDate) return true; - - const releaseTime = new Date(args.releaseDate).getTime(); - if (Number.isNaN(releaseTime)) return true; - - const ageHours = (args.now - releaseTime) / 3_600_000; - if (ageHours < 0) return false; - - const pct = Math.min(100, (ageHours / args.rolloutHours) * 100); - return args.bucket * 100 < pct; -} - -export function bucketFromStagingUserId(stagingUserId: string): number { - return UUID.parse(stagingUserId).readUInt32BE(12) / 0x100000000; + return shouldAdmitAppUpdate({ ...args, intent: "automatic" }); } export async function resolveStagingUserId(filePath: string): Promise { @@ -106,100 +76,74 @@ export function getStagingUserId(): Promise { return cachedStagingUserIdPromise; } -// --------------------------------------------------------------------------- -// Configuration -// --------------------------------------------------------------------------- +class ElectronAppUpdateRuntime implements AppUpdateRuntime { + private configured = false; -function configureAutoUpdater(releaseChannel: AppReleaseChannel): void { - // Download updates in the background and only prompt once they are ready to install. - autoUpdater.autoDownload = true; - autoUpdater.autoInstallOnAppQuit = true; + configure(input: AppUpdateRuntimeConfiguration): void { + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.autoRunAppAfterInstall = true; + autoUpdater.allowPrerelease = input.releaseChannel === "beta"; + autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest"; + autoUpdater.allowDowngrade = false; + autoUpdater.isUserWithinRollout = async (info) => { + try { + return await input.shouldAdmitUpdate(info as RuntimeUpdateInfo); + } catch { + return true; + } + }; - // Suppress built-in dialogs; the renderer handles UI. - autoUpdater.autoRunAppAfterInstall = true; - autoUpdater.allowPrerelease = releaseChannel === "beta"; - autoUpdater.channel = releaseChannel === "beta" ? "beta" : "latest"; - autoUpdater.allowDowngrade = false; - autoUpdater.isUserWithinRollout = async (info) => { - try { - const parsed = rolloutManifestSchema.parse(info); - const stagingUserId = await getStagingUserId(); + if (this.configured) return; + this.configured = true; - return shouldAdmitToRollout({ - channel: releaseChannel, - rolloutHours: parsed.rolloutHours, - releaseDate: parsed.releaseDate, - now: Date.now(), - bucket: bucketFromStagingUserId(stagingUserId), - }); - } catch { - return true; - } - }; - - if (configuredReleaseChannel !== releaseChannel) { - cachedUpdateInfo = null; - downloadedUpdateVersion = null; - downloading = false; - configuredReleaseChannel = releaseChannel; + autoUpdater.on("update-available", (info) => { + input.onUpdateAvailable(info as RuntimeUpdateInfo); + }); + autoUpdater.on("update-downloaded", (info) => { + input.onUpdateDownloaded(info as RuntimeUpdateInfo); + }); + autoUpdater.on("update-not-available", () => { + input.onUpdateNotAvailable(); + }); + autoUpdater.on("error", (error) => { + input.onError(error); + }); } - if (autoUpdaterConfigured) { - return; + async checkForUpdates(): Promise { + const result = await autoUpdater.checkForUpdates(); + if (!result) return null; + return { + isUpdateAvailable: result.isUpdateAvailable, + updateInfo: result.updateInfo as RuntimeUpdateInfo, + }; } - autoUpdaterConfigured = true; + downloadUpdate(): Promise { + return autoUpdater.downloadUpdate(); + } - autoUpdater.on("update-available", (info) => { - cachedUpdateInfo = info; - downloadedUpdateVersion = null; - downloading = true; - }); + quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + autoUpdater.quitAndInstall(isSilent, isForceRunAfter); + } +} - autoUpdater.on("update-downloaded", (info) => { - cachedUpdateInfo = info; - downloadedUpdateVersion = info.version; - downloading = false; - }); - - autoUpdater.on("update-not-available", () => { - cachedUpdateInfo = null; - downloadedUpdateVersion = null; - downloading = false; - }); - - autoUpdater.on("error", (error) => { - downloading = false; +const appUpdateService = createAppUpdateService({ + runtime: new ElectronAppUpdateRuntime(), + isPackaged: () => app.isPackaged, + now: () => Date.now(), + bucket: async () => bucketFromStagingUserId(await getStagingUserId()), + reportCheckError: (error) => { + console.error("[auto-updater] Failed to check for updates:", error); + }, + reportRuntimeError: (error) => { console.error("[auto-updater] Updater event failed:", error); - }); -} - -function isReadyToInstallVersion(version: string): boolean { - return downloadedUpdateVersion === version; -} - -function buildCheckResult(input: { - currentVersion: string; - hasUpdate: boolean; - readyToInstall: boolean; - info?: UpdateInfo | null; -}): AppUpdateCheckResult { - const { currentVersion, hasUpdate, readyToInstall, info } = input; - - return { - hasUpdate, - readyToInstall, - currentVersion, - latestVersion: info?.version ?? currentVersion, - body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null, - date: typeof info?.releaseDate === "string" ? info.releaseDate : null, - }; -} - -async function performQuitAndInstall(onBeforeQuit?: () => Promise): Promise { - if (onBeforeQuit) await onBeforeQuit(); - autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); -} + }, + reportInstallError: (message) => { + console.error("[auto-updater] Failed to download/install update:", message); + }, +}); // --------------------------------------------------------------------------- // Public API @@ -208,73 +152,13 @@ async function performQuitAndInstall(onBeforeQuit?: () => Promise): Promis export async function checkForAppUpdate({ currentVersion, releaseChannel, + intent, }: { currentVersion: string; releaseChannel: AppReleaseChannel; + intent: AppUpdateCheckIntent; }): Promise { - if (!app.isPackaged) { - return buildCheckResult({ - currentVersion, - hasUpdate: false, - readyToInstall: false, - }); - } - - configureAutoUpdater(releaseChannel); - - const cachedVersion = cachedUpdateInfo?.version ?? null; - if (cachedVersion && cachedVersion !== currentVersion) { - return buildCheckResult({ - currentVersion, - hasUpdate: true, - readyToInstall: isReadyToInstallVersion(cachedVersion), - info: cachedUpdateInfo, - }); - } - - try { - const result = await autoUpdater.checkForUpdates(); - - if (!result || !result.updateInfo) { - 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); - return buildCheckResult({ - currentVersion, - hasUpdate: true, - readyToInstall: isReadyToInstallVersion(latestVersion), - info, - }); - } - - cachedUpdateInfo = null; - downloadedUpdateVersion = null; - downloading = false; - - return buildCheckResult({ - currentVersion, - hasUpdate: false, - readyToInstall: false, - }); - } catch (error) { - console.error("[auto-updater] Failed to check for updates:", error); - return buildCheckResult({ - currentVersion, - hasUpdate: false, - readyToInstall: false, - }); - } + return appUpdateService.checkForAppUpdate({ currentVersion, releaseChannel, intent }); } export async function downloadAndInstallUpdate( @@ -287,63 +171,8 @@ export async function downloadAndInstallUpdate( }, onBeforeQuit?: () => Promise, ): Promise { - if (!app.isPackaged) { - return { - installed: false, - version: currentVersion, - message: "Auto-update is not available in development mode.", - }; - } - - if (!cachedUpdateInfo) { - return { - installed: false, - version: currentVersion, - message: "No update available. Check for updates first.", - }; - } - - configureAutoUpdater(releaseChannel); - - const readyVersion = cachedUpdateInfo.version; - if (isReadyToInstallVersion(readyVersion)) { - await performQuitAndInstall(onBeforeQuit); - return { - installed: true, - version: readyVersion, - message: "Update downloaded. The app will restart shortly.", - }; - } - - if (downloading) { - return { - installed: false, - version: currentVersion, - message: "Update is still being prepared. Try again in a moment.", - }; - } - - downloading = true; - - try { - await autoUpdater.downloadUpdate(); - downloadedUpdateVersion = readyVersion; - downloading = false; - await performQuitAndInstall(onBeforeQuit); - - return { - installed: true, - version: readyVersion, - message: "Update downloaded. The app will restart shortly.", - }; - } catch (error) { - downloading = false; - const message = error instanceof Error ? error.message : String(error); - console.error("[auto-updater] Failed to download/install update:", message); - return { - installed: false, - version: currentVersion, - message: `Update failed: ${message}`, - }; - } + return appUpdateService.downloadAndInstallUpdate( + { currentVersion, releaseChannel }, + onBeforeQuit, + ); }