Files
paseo/packages/desktop/src/features/auto-updater.ts
T
Mohamed BoudraandGitHub 70472dc945 Always install the newest eligible desktop update (#2149)
* Reapply "Always revalidate desktop updates before install"

This reverts commit 623c05aa4d.

* fix(desktop): make update revalidation safe on quit

Updater-triggered quits now bypass normal quit handling, and manifest revalidation gives up after five seconds without allowing a late install.

* fix(desktop): preserve fail-closed update installs

Keep cached updates deferred when quit-time validation is offline, and report validation timeouts separately from superseded releases.

* fix(desktop): preserve updater quit behavior

Automatic installs remain silent without relaunching, updater handoff is bounded, and background preparation failures remain visible without pinning manifest checks.

* fix(desktop): keep AppImage updates manual

Preserve the AppImage safety exemption by skipping ordinary quit-time installation while retaining the explicit Update now path.

* fix(desktop): serialize update preparation

* fix(desktop): recognize macOS updater quit handoff
2026-07-17 10:06:32 +02:00

213 lines
6.1 KiB
TypeScript

import { randomBytes } from "node:crypto";
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 } 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";
export {
bucketFromStagingUserId,
rolloutManifestSchema,
shouldAdmitAppUpdate,
type AppReleaseChannel,
type AppUpdateCheckIntent,
type AppUpdateCheckResult,
type AppUpdateInstallResult,
};
let cachedStagingUserIdPromise: Promise<string> | null = null;
export function shouldAdmitToRollout(args: {
channel: AppReleaseChannel;
rolloutHours: number | undefined;
releaseDate: string | undefined;
now: number;
bucket: number;
}): boolean {
return shouldAdmitAppUpdate({ ...args, intent: "automatic" });
}
export async function resolveStagingUserId(filePath: string): Promise<string> {
try {
const id = (await readFile(filePath, "utf8")).trim();
if (UUID.check(id)) {
return id;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
console.warn(`[auto-updater] Couldn't read staging user ID, creating a blank one: ${error}`);
}
}
const id = UUID.v5(randomBytes(4096), UUID.OID);
try {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, id);
} catch (error) {
console.warn(`[auto-updater] Couldn't write out staging user ID: ${error}`);
}
return id;
}
export function getStagingUserId(): Promise<string> {
if (cachedStagingUserIdPromise == null) {
cachedStagingUserIdPromise = resolveStagingUserId(
path.join(app.getPath("userData"), ".updaterId"),
);
}
return cachedStagingUserIdPromise;
}
export function shouldInstallAppUpdateOnQuit(input: {
platform: NodeJS.Platform;
isAppImage: boolean;
}): boolean {
// AppImage's no-relaunch install path blocks while launching the replacement
// binary, which can hang after the running file has already been replaced.
return !(input.platform === "linux" && input.isAppImage);
}
class ElectronAppUpdateRuntime implements AppUpdateRuntime {
private configured = false;
configure(input: AppUpdateRuntimeConfiguration): void {
autoUpdater.autoDownload = true;
autoUpdater.autoRunAppAfterInstall = true;
// Paseo revalidates the current manifest before explicitly installing on quit.
// Electron's built-in handler would install an older download without checking
// whether a newer release has superseded it.
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.allowPrerelease = input.releaseChannel === "beta";
autoUpdater.channel = input.releaseChannel === "beta" ? "beta" : "latest";
autoUpdater.allowDowngrade = false;
autoUpdater.isUserWithinRollout = async (info) => {
try {
return await input.shouldAdmitUpdate(info as RuntimeUpdateInfo);
} catch {
return true;
}
};
if (this.configured) return;
this.configured = true;
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);
});
}
async checkForUpdates(): Promise<RuntimeUpdateCheckResult | null> {
const result = await autoUpdater.checkForUpdates();
if (!result) return null;
return {
isUpdateAvailable: result.isUpdateAvailable,
updateInfo: result.updateInfo as RuntimeUpdateInfo,
};
}
downloadUpdate(): Promise<unknown> {
return autoUpdater.downloadUpdate();
}
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void {
autoUpdater.autoRunAppAfterInstall = isForceRunAfter;
autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
}
}
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);
},
reportInstallError: (message) => {
console.error("[auto-updater] Failed to download/install update:", message);
},
});
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function checkForAppUpdate({
currentVersion,
releaseChannel,
intent,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
intent: AppUpdateCheckIntent;
}): Promise<AppUpdateCheckResult> {
return appUpdateService.checkForAppUpdate({ currentVersion, releaseChannel, intent });
}
export async function downloadAndInstallUpdate(
{
currentVersion,
releaseChannel,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
},
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult> {
return appUpdateService.downloadAndInstallUpdate(
{ currentVersion, releaseChannel },
onBeforeQuit,
);
}
export async function installAppUpdateOnQuit({
currentVersion,
releaseChannel,
signal,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
signal: AbortSignal;
}): Promise<boolean> {
if (
!shouldInstallAppUpdateOnQuit({
platform: process.platform,
isAppImage: Boolean(process.env.APPIMAGE),
})
) {
return false;
}
return appUpdateService.installUpdateOnQuit({ currentVersion, releaseChannel, signal });
}