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
This commit is contained in:
Mohamed Boudra
2026-07-17 10:06:32 +02:00
committed by GitHub
parent a8ebd390fa
commit 70472dc945
8 changed files with 909 additions and 328 deletions
@@ -62,6 +62,11 @@ export interface AppUpdateService {
},
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult>;
installUpdateOnQuit(input: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
signal: AbortSignal;
}): Promise<boolean>;
}
export interface AppUpdateServiceDeps {
@@ -96,10 +101,16 @@ function buildCheckResult(input: {
async function performQuitAndInstall(
runtime: AppUpdateRuntime,
onBeforeQuit?: () => Promise<void>,
{
onBeforeQuit,
restart,
}: {
onBeforeQuit?: () => Promise<void>;
restart: boolean;
},
): Promise<void> {
if (onBeforeQuit) await onBeforeQuit();
runtime.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
runtime.quitAndInstall(/* isSilent */ !restart, /* isForceRunAfter */ restart);
}
function getErrorMessage(error: unknown): string {
@@ -109,13 +120,21 @@ function getErrorMessage(error: unknown): string {
return String(error);
}
function buildDeferredInstallResult(currentVersion: string): AppUpdateInstallResult {
return {
installed: false,
version: currentVersion,
message: "Update validation timed out. The update will be installed later.",
};
}
export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateService {
let cachedUpdateInfo: RuntimeUpdateInfo | null = null;
let downloadedUpdateVersion: string | null = null;
let downloading = false;
let configuredReleaseChannel: AppReleaseChannel | null = null;
let runtimeErrorMessage: string | null = null;
let inFlightUpdateCheckCount = 0;
let preparationError: { version: string; message: string } | null = null;
let preparingUpdateVersion: string | null = null;
let checkQueue: Promise<void> = Promise.resolve();
function isReadyToInstallVersion(version: string): boolean {
return downloadedUpdateVersion === version;
@@ -124,23 +143,8 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
function clearUpdateState(): void {
cachedUpdateInfo = null;
downloadedUpdateVersion = null;
downloading = false;
runtimeErrorMessage = null;
}
function buildRuntimeErrorResult(currentVersion: string): AppUpdateCheckResult | null {
if (!runtimeErrorMessage) {
return null;
}
const info = cachedUpdateInfo;
return buildCheckResult({
currentVersion,
hasUpdate: info?.version !== undefined && info.version !== currentVersion,
readyToInstall: false,
info,
errorMessage: runtimeErrorMessage,
});
preparationError = null;
preparingUpdateVersion = null;
}
function configureRuntime(releaseChannel: AppReleaseChannel, intent: AppUpdateCheckIntent): void {
@@ -166,28 +170,47 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
const alreadyReady = downloadedUpdateVersion === info.version;
cachedUpdateInfo = info;
downloadedUpdateVersion = alreadyReady ? info.version : null;
downloading = !alreadyReady;
runtimeErrorMessage = null;
if (!alreadyReady && preparingUpdateVersion === null) {
preparingUpdateVersion = info.version;
}
},
onUpdateDownloaded(info) {
cachedUpdateInfo = info;
// A superseded download can finish after a newer manifest check. Keep
// the validated manifest as the install target in that case.
cachedUpdateInfo ??= info;
downloadedUpdateVersion = info.version;
downloading = false;
runtimeErrorMessage = null;
if (preparingUpdateVersion === info.version) {
preparingUpdateVersion = null;
}
if (preparationError?.version === info.version) {
preparationError = null;
}
},
onUpdateNotAvailable() {
clearUpdateState();
},
onError(error) {
downloading = false;
if (inFlightUpdateCheckCount === 0 || cachedUpdateInfo) {
runtimeErrorMessage = getErrorMessage(error);
if (preparingUpdateVersion) {
preparationError = {
version: preparingUpdateVersion,
message: getErrorMessage(error),
};
preparingUpdateVersion = null;
}
deps.reportRuntimeError?.(error);
},
});
}
function runCheckExclusively<T>(check: () => Promise<T>): Promise<T> {
const result = checkQueue.then(check, check);
checkQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}
async function checkForAppUpdate({
currentVersion,
releaseChannel,
@@ -205,73 +228,56 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
});
}
configureRuntime(releaseChannel, intent);
return runCheckExclusively(async () => {
configureRuntime(releaseChannel, intent);
const runtimeErrorResult = buildRuntimeErrorResult(currentVersion);
if (runtimeErrorResult && intent === "automatic") {
return runtimeErrorResult;
}
try {
const result = await deps.runtime.checkForUpdates();
if (!result || !result.updateInfo || !result.isUpdateAvailable) {
clearUpdateState();
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
}
const cachedVersion = cachedUpdateInfo?.version ?? null;
if (
!runtimeErrorResult &&
intent === "automatic" &&
cachedVersion &&
cachedVersion !== currentVersion
) {
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(cachedVersion),
info: cachedUpdateInfo,
});
}
const info = result.updateInfo;
const latestVersion = info.version;
const hasUpdate = latestVersion !== currentVersion;
if (hasUpdate) {
cachedUpdateInfo = info;
const errorMessage =
preparationError?.version === latestVersion ? preparationError.message : null;
if (!errorMessage) {
preparationError = null;
}
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(latestVersion),
info,
errorMessage,
});
}
try {
inFlightUpdateCheckCount += 1;
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);
runtimeErrorMessage = null;
} catch (error) {
deps.reportCheckError?.(error);
return buildCheckResult({
currentVersion,
hasUpdate: true,
readyToInstall: isReadyToInstallVersion(latestVersion),
info,
hasUpdate: false,
readyToInstall: false,
errorMessage: getErrorMessage(error),
});
}
clearUpdateState();
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
});
} catch (error) {
deps.reportCheckError?.(error);
return buildCheckResult({
currentVersion,
hasUpdate: false,
readyToInstall: false,
errorMessage: getErrorMessage(error),
});
} finally {
inFlightUpdateCheckCount -= 1;
}
});
}
async function downloadAndInstallUpdate(
@@ -292,6 +298,69 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
};
}
const check = await checkForAppUpdate({
currentVersion,
releaseChannel,
intent: "manual",
});
if (!check.hasUpdate) {
return {
installed: false,
version: currentVersion,
message: check.errorMessage ?? "No update available.",
};
}
return installCachedUpdate(currentVersion, { onBeforeQuit, restart: true });
}
async function ensureUpdateDownloaded(
readyVersion: string,
signal?: AbortSignal,
): Promise<"ready" | "aborted" | "superseded"> {
while (!isReadyToInstallVersion(readyVersion)) {
if (signal?.aborted) return "aborted";
if (cachedUpdateInfo?.version !== readyVersion) return "superseded";
const attemptedVersion: string = preparingUpdateVersion ?? readyVersion;
preparingUpdateVersion ??= readyVersion;
try {
await deps.runtime.downloadUpdate();
} catch (error) {
if (
attemptedVersion !== readyVersion &&
cachedUpdateInfo?.version === readyVersion &&
!signal?.aborted
) {
continue;
}
throw error;
}
// electron-updater can return an older, already-running download. Its
// event clears that version, then the next iteration starts the newly
// validated release instead of treating the stale artifact as ready.
if (attemptedVersion === readyVersion && !isReadyToInstallVersion(readyVersion)) {
downloadedUpdateVersion = readyVersion;
preparingUpdateVersion = null;
}
}
return signal?.aborted ? "aborted" : "ready";
}
async function installCachedUpdate(
currentVersion: string,
{
onBeforeQuit,
signal,
restart,
}: {
onBeforeQuit?: () => Promise<void>;
signal?: AbortSignal;
restart: boolean;
},
): Promise<AppUpdateInstallResult> {
if (!cachedUpdateInfo) {
return {
installed: false,
@@ -300,11 +369,13 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
};
}
configureRuntime(releaseChannel, "manual");
const readyVersion = cachedUpdateInfo.version;
if (signal?.aborted) {
return buildDeferredInstallResult(currentVersion);
}
if (isReadyToInstallVersion(readyVersion)) {
await performQuitAndInstall(deps.runtime, onBeforeQuit);
await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart });
return {
installed: true,
version: readyVersion,
@@ -312,21 +383,19 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
};
}
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);
const preparation = await ensureUpdateDownloaded(readyVersion, signal);
if (preparation === "aborted") {
return buildDeferredInstallResult(currentVersion);
}
if (preparation === "superseded") {
return {
installed: false,
version: currentVersion,
message: "A newer update was found and will be installed later.",
};
}
await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart });
return {
installed: true,
@@ -334,7 +403,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
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 {
@@ -345,8 +413,35 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
}
}
async function installUpdateOnQuit({
currentVersion,
releaseChannel,
signal,
}: {
currentVersion: string;
releaseChannel: AppReleaseChannel;
signal: AbortSignal;
}): Promise<boolean> {
if (!deps.isPackaged() || !downloadedUpdateVersion) {
return false;
}
const check = await checkForAppUpdate({
currentVersion,
releaseChannel,
intent: "automatic",
});
if (signal.aborted || !check.hasUpdate) {
return false;
}
const result = await installCachedUpdate(currentVersion, { signal, restart: false });
return result.installed;
}
return {
checkForAppUpdate,
downloadAndInstallUpdate,
installUpdateOnQuit,
};
}