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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user