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.
This commit is contained in:
Mohamed Boudra
2026-06-09 15:47:30 +07:00
parent e72b0773e6
commit eca0a5bf67
14 changed files with 809 additions and 284 deletions

View File

@@ -89,7 +89,7 @@ Use the beta path when you need to:
## Staged rollout (stable channel) ## 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`. 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.<N+
- **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.** 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 rollback.** `allowDowngrade = false`. Bad release = ship a hotfix. - **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. - **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 admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window. - **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.
## Mobile builds (EAS) ## Mobile builds (EAS)

View File

@@ -38,13 +38,22 @@ function createUpdater(
} }
describe("desktop app updater — check", () => { describe("desktop app updater — check", () => {
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(); const { updater, port } = createUpdater();
port.nextCheckResult(buildFakeCheckResult()); port.nextCheckResult(buildFakeCheckResult());
await updater.checkForUpdates({ releaseChannel: "beta" }); 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 () => { 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"); expect(updater.getSnapshot().status).toBe("available");
const deferred = port.deferNextCheck(); 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"); expect(updater.getSnapshot().status).toBe("available");
deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true })); deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
@@ -128,7 +141,7 @@ describe("desktop app updater — check", () => {
const statusBeforeSilent = updater.getSnapshot().status; const statusBeforeSilent = updater.getSnapshot().status;
port.failNextCheck(new Error("boom")); 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); expect(updater.getSnapshot().status).toBe(statusBeforeSilent);
}); });
@@ -226,6 +239,20 @@ describe("desktop app updater — subscribe", () => {
describe("formatStatusText", () => { describe("formatStatusText", () => {
const formatVersion = (version: string | null | undefined) => const formatVersion = (version: string | null | undefined) =>
version ? `v${version.replace(/^v/i, "")}` : "\u2014"; 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", () => { it("uses the latest version in the 'available' message when present", () => {
expect( expect(
@@ -233,7 +260,9 @@ describe("formatStatusText", () => {
status: "available", status: "available",
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }), availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null, installMessage: null,
lastCheckedAt: null,
formatVersion, formatVersion,
formatLastCheckedAt,
}), }),
).toBe("Update ready: v1.2.3"); ).toBe("Update ready: v1.2.3");
}); });
@@ -244,7 +273,9 @@ describe("formatStatusText", () => {
status: "available", status: "available",
availableUpdate: null, availableUpdate: null,
installMessage: null, installMessage: null,
lastCheckedAt: null,
formatVersion, formatVersion,
formatLastCheckedAt,
}), }),
).toBe("An app update is ready to install."); ).toBe("An app update is ready to install.");
}); });
@@ -255,7 +286,9 @@ describe("formatStatusText", () => {
status: "installed", status: "installed",
availableUpdate: null, availableUpdate: null,
installMessage: "Restart now", installMessage: "Restart now",
lastCheckedAt: null,
formatVersion, formatVersion,
formatLastCheckedAt,
}), }),
).toBe("Restart now"); ).toBe("Restart now");
}); });

View File

@@ -1,5 +1,6 @@
import type { import type {
DesktopAppUpdateCheckResult, DesktopAppUpdateCheckResult,
DesktopAppUpdateCheckIntent,
DesktopAppUpdateInstallResult, DesktopAppUpdateInstallResult,
DesktopReleaseChannel, DesktopReleaseChannel,
} from "@/desktop/updates/desktop-updates"; } from "@/desktop/updates/desktop-updates";
@@ -29,6 +30,7 @@ export interface DesktopAppUpdaterSnapshot {
export interface DesktopAppUpdaterPort { export interface DesktopAppUpdaterPort {
checkDesktopAppUpdate(input: { checkDesktopAppUpdate(input: {
releaseChannel: DesktopReleaseChannel; releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}): Promise<DesktopAppUpdateCheckResult>; }): Promise<DesktopAppUpdateCheckResult>;
installDesktopAppUpdate(input: { installDesktopAppUpdate(input: {
releaseChannel: DesktopReleaseChannel; releaseChannel: DesktopReleaseChannel;
@@ -52,6 +54,7 @@ export interface DesktopAppUpdater {
subscribe(listener: () => void): () => void; subscribe(listener: () => void): () => void;
checkForUpdates(options?: { checkForUpdates(options?: {
releaseChannel: DesktopReleaseChannel; releaseChannel: DesktopReleaseChannel;
intent?: DesktopAppUpdateCheckIntent;
silent?: boolean; silent?: boolean;
}): Promise<DesktopAppUpdateCheckResult | null>; }): Promise<DesktopAppUpdateCheckResult | null>;
installUpdate(options: { installUpdate(options: {
@@ -102,9 +105,18 @@ export function formatStatusText(input: {
status: DesktopAppUpdateStatus; status: DesktopAppUpdateStatus;
availableUpdate: DesktopAppUpdateCheckResult | null; availableUpdate: DesktopAppUpdateCheckResult | null;
installMessage: string | null; installMessage: string | null;
lastCheckedAt: number | null;
formatVersion: (version: string | null | undefined) => string; formatVersion: (version: string | null | undefined) => string;
formatLastCheckedAt: (timestamp: number) => string;
}): string { }): string {
const { status, availableUpdate, installMessage, formatVersion } = input; const {
status,
availableUpdate,
installMessage,
lastCheckedAt,
formatVersion,
formatLastCheckedAt,
} = input;
if (status === "checking") { if (status === "checking") {
return "Checking for app updates..."; return "Checking for app updates...";
@@ -115,7 +127,10 @@ export function formatStatusText(input: {
} }
if (status === "up-to-date") { 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") { if (status === "pending") {
@@ -155,12 +170,13 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
async function checkForUpdates(options?: { async function checkForUpdates(options?: {
releaseChannel: DesktopReleaseChannel; releaseChannel: DesktopReleaseChannel;
intent?: DesktopAppUpdateCheckIntent;
silent?: boolean; silent?: boolean;
}): Promise<DesktopAppUpdateCheckResult | null> { }): Promise<DesktopAppUpdateCheckResult | null> {
if (!options) { if (!options) {
return null; return null;
} }
const { releaseChannel, silent = false } = options; const { releaseChannel, intent = "manual", silent = false } = options;
const requestVersion = state.requestVersion + 1; const requestVersion = state.requestVersion + 1;
commit({ commit({
@@ -171,7 +187,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
}); });
try { try {
const result = await deps.port.checkDesktopAppUpdate({ releaseChannel }); const result = await deps.port.checkDesktopAppUpdate({ releaseChannel, intent });
if (requestVersion !== state.requestVersion) { if (requestVersion !== state.requestVersion) {
return result; return result;
} }

View File

@@ -23,6 +23,7 @@ export interface DesktopRuntimeInfo {
} }
export type DesktopReleaseChannel = "stable" | "beta"; export type DesktopReleaseChannel = "stable" | "beta";
export type DesktopAppUpdateCheckIntent = "automatic" | "manual";
export interface LocalDaemonUpdateResult { export interface LocalDaemonUpdateResult {
exitCode: number; exitCode: number;
@@ -99,10 +100,15 @@ export async function getDesktopRuntimeInfo(): Promise<DesktopRuntimeInfo> {
export async function checkDesktopAppUpdate({ export async function checkDesktopAppUpdate({
releaseChannel, releaseChannel,
intent,
}: { }: {
releaseChannel: DesktopReleaseChannel; releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}): Promise<DesktopAppUpdateCheckResult> { }): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel }); const result = await invokeDesktopCommand<unknown>("check_app_update", {
releaseChannel,
intent,
});
if (!isRecord(result)) { if (!isRecord(result)) {
throw new Error("Unexpected response while checking desktop updates."); throw new Error("Unexpected response while checking desktop updates.");
} }

View File

@@ -1,12 +1,16 @@
import type { import type {
DesktopAppUpdateCheckResult, DesktopAppUpdateCheckResult,
DesktopAppUpdateCheckIntent,
DesktopAppUpdateInstallResult, DesktopAppUpdateInstallResult,
DesktopReleaseChannel, DesktopReleaseChannel,
} from "@/desktop/updates/desktop-updates"; } from "@/desktop/updates/desktop-updates";
import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater"; import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater";
export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort { export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort {
readonly recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }>; readonly recordedChecks: Array<{
releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}>;
readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>; readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>;
nextCheckResult(result: DesktopAppUpdateCheckResult): void; nextCheckResult(result: DesktopAppUpdateCheckResult): void;
deferNextCheck(): { deferNextCheck(): {
@@ -53,7 +57,10 @@ function buildInstallResult(
} }
export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort { export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort {
const recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }> = []; const recordedChecks: Array<{
releaseChannel: DesktopReleaseChannel;
intent: DesktopAppUpdateCheckIntent;
}> = [];
const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = []; const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
const checkOutcomes: CheckOutcome[] = []; const checkOutcomes: CheckOutcome[] = [];
const installOutcomes: InstallOutcome[] = []; const installOutcomes: InstallOutcome[] = [];

View File

@@ -62,10 +62,10 @@ export function UpdateCalloutSource() {
useEffect(() => { useEffect(() => {
if (!isDesktopApp) return; if (!isDesktopApp) return;
void checkForUpdates({ silent: true }); void checkForUpdates({ intent: "automatic", silent: true });
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(() => {
void checkForUpdates({ silent: true }); void checkForUpdates({ intent: "automatic", silent: true });
}, CHECK_INTERVAL_MS); }, CHECK_INTERVAL_MS);
return () => { return () => {

View File

@@ -5,6 +5,7 @@ import {
installDesktopAppUpdate, installDesktopAppUpdate,
shouldShowDesktopUpdateSection, shouldShowDesktopUpdateSection,
type DesktopAppUpdateCheckResult, type DesktopAppUpdateCheckResult,
type DesktopAppUpdateCheckIntent,
type DesktopAppUpdateInstallResult, type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates"; } from "@/desktop/updates/desktop-updates";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
@@ -15,6 +16,7 @@ import {
formatStatusText, formatStatusText,
type DesktopAppUpdateStatus, type DesktopAppUpdateStatus,
} from "@/desktop/updates/desktop-app-updater"; } from "@/desktop/updates/desktop-app-updater";
import { formatMessageTimestamp } from "@/utils/time";
export type { DesktopAppUpdateStatus }; export type { DesktopAppUpdateStatus };
@@ -27,7 +29,10 @@ export interface UseDesktopAppUpdaterReturn {
lastCheckedAt: number | null; lastCheckedAt: number | null;
isChecking: boolean; isChecking: boolean;
isInstalling: boolean; isInstalling: boolean;
checkForUpdates: (options?: { silent?: boolean }) => Promise<DesktopAppUpdateCheckResult | null>; checkForUpdates: (options?: {
intent?: DesktopAppUpdateCheckIntent;
silent?: boolean;
}) => Promise<DesktopAppUpdateCheckResult | null>;
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>; installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>;
} }
@@ -57,11 +62,15 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
); );
const checkForUpdates = useCallback( const checkForUpdates = useCallback(
async (options: { silent?: boolean } = {}) => { async (options: { intent?: DesktopAppUpdateCheckIntent; silent?: boolean } = {}) => {
if (!isDesktopApp) { if (!isDesktopApp) {
return null; return null;
} }
return updater.checkForUpdates({ releaseChannel, silent: options.silent }); return updater.checkForUpdates({
releaseChannel,
intent: options.intent ?? "manual",
silent: options.silent,
});
}, },
[isDesktopApp, releaseChannel, updater], [isDesktopApp, releaseChannel, updater],
); );
@@ -77,7 +86,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
if (!isDesktopApp) { if (!isDesktopApp) {
return; return;
} }
void checkForUpdates({ silent: true }); void checkForUpdates({ intent: "automatic", silent: true });
}, [checkForUpdates, isDesktopApp]); }, [checkForUpdates, isDesktopApp]);
useEffect(() => { useEffect(() => {
@@ -86,7 +95,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
} }
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
void checkForUpdates({ silent: true }); void checkForUpdates({ intent: "automatic", silent: true });
}, PENDING_RECHECK_MS); }, PENDING_RECHECK_MS);
return () => { return () => {
@@ -101,7 +110,9 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
status: snapshot.status, status: snapshot.status,
availableUpdate: snapshot.availableUpdate, availableUpdate: snapshot.availableUpdate,
installMessage: snapshot.installMessage, installMessage: snapshot.installMessage,
lastCheckedAt: snapshot.lastCheckedAt,
formatVersion: formatVersionWithPrefix, formatVersion: formatVersionWithPrefix,
formatLastCheckedAt: (timestamp) => formatMessageTimestamp(new Date(timestamp)),
}), }),
availableUpdate: snapshot.availableUpdate, availableUpdate: snapshot.availableUpdate,
errorMessage: snapshot.errorMessage, errorMessage: snapshot.errorMessage,

View File

@@ -523,7 +523,7 @@ function DesktopAppUpdateRow() {
if (!isDesktopApp) { if (!isDesktopApp) {
return undefined; return undefined;
} }
void checkForUpdates({ silent: true }); void checkForUpdates({ intent: "automatic", silent: true });
return undefined; return undefined;
}, [checkForUpdates, isDesktopApp]), }, [checkForUpdates, isDesktopApp]),
); );

View File

@@ -15,6 +15,7 @@ import {
import { import {
checkForAppUpdate, checkForAppUpdate,
downloadAndInstallUpdate, downloadAndInstallUpdate,
type AppUpdateCheckIntent,
type AppReleaseChannel, type AppReleaseChannel,
} from "../features/auto-updater.js"; } from "../features/auto-updater.js";
import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js"; import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js";
@@ -81,6 +82,12 @@ function parseReleaseChannel(
return undefined; return undefined;
} }
function parseAppUpdateCheckIntent(
args: Record<string, unknown> | undefined,
): AppUpdateCheckIntent {
return args?.intent === "manual" ? "manual" : "automatic";
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Utilities // Utilities
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -517,6 +524,7 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
return checkForAppUpdate({ return checkForAppUpdate({
currentVersion, currentVersion,
releaseChannel: await resolveRequestedReleaseChannel(args), releaseChannel: await resolveRequestedReleaseChannel(args),
intent: parseAppUpdateCheckIntent(args),
}); });
}, },
install_app_update: async (args) => { install_app_update: async (args) => {

View File

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

View File

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

View File

@@ -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<boolean>) | 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<void> {}
quitAndInstall(): void {}
}
function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) {
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,
});
});
});

View File

@@ -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<boolean>;
onUpdateAvailable(info: RuntimeUpdateInfo): void;
onUpdateDownloaded(info: RuntimeUpdateInfo): void;
onUpdateNotAvailable(): void;
onError(error: unknown): void;
}
export interface AppUpdateRuntime {
configure(input: AppUpdateRuntimeConfiguration): void;
checkForUpdates(): Promise<RuntimeUpdateCheckResult | null>;
downloadUpdate(): Promise<unknown>;
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void;
}
export interface AppUpdateService {
checkForAppUpdate(input: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
intent: AppUpdateCheckIntent;
}): Promise<AppUpdateCheckResult>;
downloadAndInstallUpdate(
input: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
},
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult>;
}
export interface AppUpdateServiceDeps {
runtime: AppUpdateRuntime;
isPackaged(): boolean;
now(): number;
bucket(): Promise<number>;
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<void>,
): Promise<void> {
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<AppUpdateCheckResult> {
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<void>,
): Promise<AppUpdateInstallResult> {
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,
};
}

View File

@@ -3,48 +3,34 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { app } from "electron"; import { app } from "electron";
import { UUID } from "builder-util-runtime"; import { UUID } from "builder-util-runtime";
import { autoUpdater, type UpdateInfo } from "electron-updater"; import { autoUpdater } from "electron-updater";
import { z } from "zod"; 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";
// --------------------------------------------------------------------------- export {
// Types 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<string> | null = null; let cachedStagingUserIdPromise: Promise<string> | null = null;
export function shouldAdmitToRollout(args: { export function shouldAdmitToRollout(args: {
@@ -54,23 +40,7 @@ export function shouldAdmitToRollout(args: {
now: number; now: number;
bucket: number; bucket: number;
}): boolean { }): boolean {
if (args.channel !== "stable") return true; return shouldAdmitAppUpdate({ ...args, intent: "automatic" });
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;
} }
export async function resolveStagingUserId(filePath: string): Promise<string> { export async function resolveStagingUserId(filePath: string): Promise<string> {
@@ -106,100 +76,74 @@ export function getStagingUserId(): Promise<string> {
return cachedStagingUserIdPromise; return cachedStagingUserIdPromise;
} }
// --------------------------------------------------------------------------- class ElectronAppUpdateRuntime implements AppUpdateRuntime {
// Configuration private configured = false;
// ---------------------------------------------------------------------------
function configureAutoUpdater(releaseChannel: AppReleaseChannel): void { configure(input: AppUpdateRuntimeConfiguration): void {
// Download updates in the background and only prompt once they are ready to install. autoUpdater.autoDownload = true;
autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = 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. if (this.configured) return;
autoUpdater.autoRunAppAfterInstall = true; this.configured = 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();
return shouldAdmitToRollout({ autoUpdater.on("update-available", (info) => {
channel: releaseChannel, input.onUpdateAvailable(info as RuntimeUpdateInfo);
rolloutHours: parsed.rolloutHours, });
releaseDate: parsed.releaseDate, autoUpdater.on("update-downloaded", (info) => {
now: Date.now(), input.onUpdateDownloaded(info as RuntimeUpdateInfo);
bucket: bucketFromStagingUserId(stagingUserId), });
}); autoUpdater.on("update-not-available", () => {
} catch { input.onUpdateNotAvailable();
return true; });
} autoUpdater.on("error", (error) => {
}; input.onError(error);
});
if (configuredReleaseChannel !== releaseChannel) {
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
configuredReleaseChannel = releaseChannel;
} }
if (autoUpdaterConfigured) { async checkForUpdates(): Promise<RuntimeUpdateCheckResult | null> {
return; const result = await autoUpdater.checkForUpdates();
if (!result) return null;
return {
isUpdateAvailable: result.isUpdateAvailable,
updateInfo: result.updateInfo as RuntimeUpdateInfo,
};
} }
autoUpdaterConfigured = true; downloadUpdate(): Promise<unknown> {
return autoUpdater.downloadUpdate();
}
autoUpdater.on("update-available", (info) => { quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void {
cachedUpdateInfo = info; autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
downloadedUpdateVersion = null; }
downloading = true; }
});
autoUpdater.on("update-downloaded", (info) => { const appUpdateService = createAppUpdateService({
cachedUpdateInfo = info; runtime: new ElectronAppUpdateRuntime(),
downloadedUpdateVersion = info.version; isPackaged: () => app.isPackaged,
downloading = false; now: () => Date.now(),
}); bucket: async () => bucketFromStagingUserId(await getStagingUserId()),
reportCheckError: (error) => {
autoUpdater.on("update-not-available", () => { console.error("[auto-updater] Failed to check for updates:", error);
cachedUpdateInfo = null; },
downloadedUpdateVersion = null; reportRuntimeError: (error) => {
downloading = false;
});
autoUpdater.on("error", (error) => {
downloading = false;
console.error("[auto-updater] Updater event failed:", error); console.error("[auto-updater] Updater event failed:", error);
}); },
} reportInstallError: (message) => {
console.error("[auto-updater] Failed to download/install update:", message);
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<void>): Promise<void> {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public API // Public API
@@ -208,73 +152,13 @@ async function performQuitAndInstall(onBeforeQuit?: () => Promise<void>): Promis
export async function checkForAppUpdate({ export async function checkForAppUpdate({
currentVersion, currentVersion,
releaseChannel, releaseChannel,
intent,
}: { }: {
currentVersion: string; currentVersion: string;
releaseChannel: AppReleaseChannel; releaseChannel: AppReleaseChannel;
intent: AppUpdateCheckIntent;
}): Promise<AppUpdateCheckResult> { }): Promise<AppUpdateCheckResult> {
if (!app.isPackaged) { return appUpdateService.checkForAppUpdate({ currentVersion, releaseChannel, intent });
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,
});
}
} }
export async function downloadAndInstallUpdate( export async function downloadAndInstallUpdate(
@@ -287,63 +171,8 @@ export async function downloadAndInstallUpdate(
}, },
onBeforeQuit?: () => Promise<void>, onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult> { ): Promise<AppUpdateInstallResult> {
if (!app.isPackaged) { return appUpdateService.downloadAndInstallUpdate(
return { { currentVersion, releaseChannel },
installed: false, onBeforeQuit,
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}`,
};
}
} }