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

@@ -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 { 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<string> | 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<string> {
@@ -106,100 +76,74 @@ export function getStagingUserId(): Promise<string> {
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<RuntimeUpdateCheckResult | null> {
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) => {
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<void>): Promise<void> {
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<void>): Promis
export async function checkForAppUpdate({
currentVersion,
releaseChannel,
intent,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
intent: AppUpdateCheckIntent;
}): Promise<AppUpdateCheckResult> {
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<void>,
): Promise<AppUpdateInstallResult> {
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,
);
}