* 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
213 lines
6.1 KiB
TypeScript
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 });
|
|
}
|