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:
@@ -17,7 +17,19 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
||||
> = [];
|
||||
private gate: ((info: RuntimeUpdateInfo) => boolean | Promise<boolean>) | null = null;
|
||||
private configuration: AppUpdateRuntimeConfiguration | null = null;
|
||||
private downloadableUpdate: RuntimeUpdateInfo | null = null;
|
||||
private downloadedUpdate: RuntimeUpdateInfo | null = null;
|
||||
private activeDownload: {
|
||||
info: RuntimeUpdateInfo;
|
||||
promise: Promise<void>;
|
||||
resolve(): void;
|
||||
reject(error: Error): void;
|
||||
} | null = null;
|
||||
checkCount = 0;
|
||||
downloadCallCount = 0;
|
||||
downloadedVersions: string[] = [];
|
||||
installedVersions: string[] = [];
|
||||
installModes: Array<{ isSilent: boolean; isForceRunAfter: boolean }> = [];
|
||||
|
||||
configure(input: AppUpdateRuntimeConfiguration): void {
|
||||
this.configuration = input;
|
||||
@@ -59,9 +71,42 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
||||
}
|
||||
|
||||
finishUpdateDownload(info: RuntimeUpdateInfo): void {
|
||||
this.downloadedUpdate = info;
|
||||
this.downloadedVersions.push(info.version);
|
||||
this.configuration?.onUpdateDownloaded(info);
|
||||
}
|
||||
|
||||
beginUpdateDownload(info: RuntimeUpdateInfo): {
|
||||
resolve(): void;
|
||||
reject(error: Error): void;
|
||||
} {
|
||||
this.downloadableUpdate = info;
|
||||
this.prepareUpdate(info);
|
||||
let resolvePromise!: () => void;
|
||||
let rejectPromise!: (error: Error) => void;
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
resolvePromise = resolve;
|
||||
rejectPromise = reject;
|
||||
});
|
||||
void promise.catch(() => undefined);
|
||||
const activeDownload = {
|
||||
info,
|
||||
promise,
|
||||
resolve: () => {
|
||||
this.finishUpdateDownload(info);
|
||||
this.activeDownload = null;
|
||||
resolvePromise();
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
this.configuration?.onError(error);
|
||||
this.activeDownload = null;
|
||||
rejectPromise(error);
|
||||
},
|
||||
};
|
||||
this.activeDownload = activeDownload;
|
||||
return { resolve: activeDownload.resolve, reject: activeDownload.reject };
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<{
|
||||
isUpdateAvailable: boolean;
|
||||
updateInfo: RuntimeUpdateInfo;
|
||||
@@ -80,12 +125,27 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
||||
}
|
||||
if (!result || !this.gate) return result;
|
||||
const admitted = await this.gate(result.updateInfo);
|
||||
return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted };
|
||||
const isUpdateAvailable = result.isUpdateAvailable && admitted;
|
||||
this.downloadableUpdate = isUpdateAvailable ? result.updateInfo : null;
|
||||
return { ...result, isUpdateAvailable };
|
||||
}
|
||||
|
||||
async downloadUpdate(): Promise<void> {}
|
||||
async downloadUpdate(): Promise<void> {
|
||||
this.downloadCallCount += 1;
|
||||
if (this.activeDownload) {
|
||||
return this.activeDownload.promise;
|
||||
}
|
||||
if (this.downloadableUpdate) {
|
||||
this.finishUpdateDownload(this.downloadableUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
quitAndInstall(): void {}
|
||||
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void {
|
||||
if (this.downloadedUpdate) {
|
||||
this.installedVersions.push(this.downloadedUpdate.version);
|
||||
this.installModes.push({ isSilent, isForceRunAfter });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) {
|
||||
@@ -148,6 +208,33 @@ describe("app update service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for an automatic poll before starting a manual rollout-bypassing check", async () => {
|
||||
const { runtime, service } = createService();
|
||||
const automaticCheck = runtime.deferNextCheck();
|
||||
const automaticPending = service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
const manualPending = service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(runtime.checkCount).toBe(1);
|
||||
|
||||
automaticCheck.resolve({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
|
||||
await automaticPending;
|
||||
const manualResult = await manualPending;
|
||||
|
||||
expect(runtime.checkCount).toBe(2);
|
||||
expect(manualResult.hasUpdate).toBe(true);
|
||||
expect(manualResult.latestVersion).toBe("1.2.4");
|
||||
});
|
||||
|
||||
it("performs a fresh manual check when an update is already cached", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
@@ -179,6 +266,218 @@ describe("app update service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces a downloaded update when a newer release is admitted", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.finishUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.5",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("installs the newest admitted release when quitting with an older download", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.finishUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const installed = await service.installUpdateOnQuit({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(installed).toBe(true);
|
||||
expect(runtime.installedVersions).toEqual(["1.2.5"]);
|
||||
expect(runtime.installModes).toEqual([{ isSilent: true, isForceRunAfter: false }]);
|
||||
});
|
||||
|
||||
it("does not install an older download while its replacement is still rolling out", async () => {
|
||||
const now = Date.parse("2026-04-28T12:00:00.000Z");
|
||||
const { runtime, service } = createService({ now: () => now, bucket: async () => 0.4 });
|
||||
const olderUpdate = {
|
||||
...rolledOutUpdate,
|
||||
releaseDate: "2026-04-27T00:00:00.000Z",
|
||||
};
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: olderUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.finishUpdateDownload(olderUpdate);
|
||||
|
||||
const newerUpdate = {
|
||||
...rolledOutUpdate,
|
||||
version: "1.2.5",
|
||||
releaseDate: "2026-04-28T12:00:00.000Z",
|
||||
};
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const installed = await service.installUpdateOnQuit({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(installed).toBe(false);
|
||||
expect(runtime.installedVersions).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not install after quit-time revalidation expires", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.finishUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const deadline = new AbortController();
|
||||
deadline.abort();
|
||||
runtime.nextCheck({
|
||||
isUpdateAvailable: true,
|
||||
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
|
||||
});
|
||||
const installed = await service.installUpdateOnQuit({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
signal: deadline.signal,
|
||||
});
|
||||
|
||||
expect(installed).toBe(false);
|
||||
expect(runtime.installedVersions).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not install an unvalidated download when the quit-time check fails", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.finishUpdateDownload(rolledOutUpdate);
|
||||
|
||||
runtime.failNextCheck(new Error("offline"));
|
||||
const installed = await service.installUpdateOnQuit({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(installed).toBe(false);
|
||||
expect(runtime.installedVersions).toEqual([]);
|
||||
});
|
||||
|
||||
it("rechecks for the newest release before a manual install", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0.99 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.finishUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const result = await service.downloadAndInstallUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(runtime.installedVersions).toEqual(["1.2.5"]);
|
||||
expect(runtime.installModes).toEqual([{ isSilent: false, isForceRunAfter: true }]);
|
||||
});
|
||||
|
||||
it("waits for a stale active download before downloading and installing the rechecked version", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
const staleDownload = runtime.beginUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const installPending = service.downloadAndInstallUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(runtime.installedVersions).toEqual([]);
|
||||
|
||||
staleDownload.resolve();
|
||||
const result = await installPending;
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(runtime.downloadedVersions).toEqual(["1.2.4", "1.2.5"]);
|
||||
expect(runtime.installedVersions).toEqual(["1.2.5"]);
|
||||
});
|
||||
|
||||
it("installs the rechecked version when the stale active download fails", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
const staleDownload = runtime.beginUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const installPending = service.downloadAndInstallUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(runtime.downloadCallCount).toBe(1);
|
||||
|
||||
staleDownload.reject(new Error("old download failed"));
|
||||
const result = await installPending;
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(runtime.downloadedVersions).toEqual(["1.2.5"]);
|
||||
expect(runtime.installedVersions).toEqual(["1.2.5"]);
|
||||
});
|
||||
|
||||
it("trusts the runtime availability decision before comparing versions", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
|
||||
@@ -322,28 +621,26 @@ describe("app update service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps preparation errors emitted before the update check rejects", async () => {
|
||||
const { runtime, service } = createService();
|
||||
const deferredCheck = runtime.deferNextCheck();
|
||||
const pending = service.checkForAppUpdate({
|
||||
it("surfaces preparation errors without blocking newer releases", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
runtime.prepareUpdate(rolledOutUpdate);
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
deferredCheck.reject(new Error("sha512 checksum mismatch"));
|
||||
const checkResult = await pending;
|
||||
expect(checkResult.errorMessage).toBe("sha512 checksum mismatch");
|
||||
|
||||
const automaticResult = await service.checkForAppUpdate({
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
const failedPreparation = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
expect(automaticResult).toEqual({
|
||||
expect(failedPreparation).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
@@ -352,19 +649,9 @@ describe("app update service", () => {
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: "sha512 checksum mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns runtime update errors after an update fails to prepare", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
@@ -375,13 +662,43 @@ describe("app update service", () => {
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.4",
|
||||
latestVersion: "1.2.5",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: "sha512 checksum mismatch",
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("attributes a late preparation failure to the download that started it", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.prepareUpdate(rolledOutUpdate);
|
||||
|
||||
const newerUpdate = { ...rolledOutUpdate, version: "1.2.5" };
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
runtime.failRuntime(new Error("old download failed"));
|
||||
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: newerUpdate });
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
expect(result.latestVersion).toBe("1.2.5");
|
||||
expect(result.errorMessage).toBeNull();
|
||||
});
|
||||
|
||||
it("performs a fresh manual check after an update preparation error", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
@@ -391,6 +708,7 @@ describe("app update service", () => {
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.prepareUpdate(rolledOutUpdate);
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
|
||||
runtime.nextCheck({
|
||||
@@ -445,91 +763,4 @@ describe("app update service", () => {
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns runtime update errors to multiple automatic checks before a manual retry clears them", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
|
||||
const firstAutomaticResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
const secondAutomaticResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
expect(firstAutomaticResult).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.4",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: "sha512 checksum mismatch",
|
||||
});
|
||||
expect(secondAutomaticResult).toEqual(firstAutomaticResult);
|
||||
|
||||
runtime.nextCheck(null);
|
||||
const retryResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
expect(runtime.checkCount).toBe(2);
|
||||
expect(retryResult).toEqual({
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.3",
|
||||
body: null,
|
||||
date: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps runtime update errors visible after a manual retry fails", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
|
||||
runtime.failNextCheck(new Error("network down"));
|
||||
const retryResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
const automaticResult = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
expect(retryResult.errorMessage).toBe("network down");
|
||||
expect(automaticResult).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.4",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: "sha512 checksum mismatch",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,15 +19,15 @@ import {
|
||||
resolveStagingUserId,
|
||||
rolloutManifestSchema,
|
||||
shouldAdmitToRollout,
|
||||
shouldAutoInstallOnQuit,
|
||||
shouldInstallAppUpdateOnQuit,
|
||||
} from "./auto-updater";
|
||||
|
||||
describe("shouldAutoInstallOnQuit", () => {
|
||||
it("auto-installs on quit everywhere except Linux AppImage", () => {
|
||||
expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: true })).toBe(false);
|
||||
expect(shouldAutoInstallOnQuit({ platform: "linux", isAppImage: false })).toBe(true);
|
||||
expect(shouldAutoInstallOnQuit({ platform: "darwin", isAppImage: false })).toBe(true);
|
||||
expect(shouldAutoInstallOnQuit({ platform: "win32", isAppImage: false })).toBe(true);
|
||||
describe("shouldInstallAppUpdateOnQuit", () => {
|
||||
it("keeps Linux AppImage updates on the manual install path", () => {
|
||||
expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: true })).toBe(false);
|
||||
expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: false })).toBe(true);
|
||||
expect(shouldInstallAppUpdateOnQuit({ platform: "darwin", isAppImage: false })).toBe(true);
|
||||
expect(shouldInstallAppUpdateOnQuit({ platform: "win32", isAppImage: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -76,18 +76,12 @@ export function getStagingUserId(): Promise<string> {
|
||||
return cachedStagingUserIdPromise;
|
||||
}
|
||||
|
||||
// AppImages have no install step. electron-updater "installs" by unlinking the
|
||||
// running file and mv-ing the downloaded one into place; on app quit it does this
|
||||
// via a *blocking* execFileSync(newAppImage, { APPIMAGE_EXIT_AFTER_INSTALL: "true" }).
|
||||
// That env var is only honored by AppImageLauncher, so without it the freshly
|
||||
// launched process boots the full app and never exits — the quit hangs forever,
|
||||
// with the old binary already deleted. We therefore install AppImages only on
|
||||
// explicit quitAndInstall (the "Update now" button), which takes the non-blocking
|
||||
// spawn path. Every other target keeps auto-install-on-quit, which works there.
|
||||
export function shouldAutoInstallOnQuit(input: {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -97,10 +91,10 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime {
|
||||
configure(input: AppUpdateRuntimeConfiguration): void {
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoRunAppAfterInstall = true;
|
||||
autoUpdater.autoInstallOnAppQuit = shouldAutoInstallOnQuit({
|
||||
platform: process.platform,
|
||||
isAppImage: Boolean(process.env.APPIMAGE),
|
||||
});
|
||||
// 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;
|
||||
@@ -143,6 +137,7 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime {
|
||||
}
|
||||
|
||||
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void {
|
||||
autoUpdater.autoRunAppAfterInstall = isForceRunAfter;
|
||||
autoUpdater.quitAndInstall(isSilent, isForceRunAfter);
|
||||
}
|
||||
}
|
||||
@@ -194,3 +189,24 @@ export async function downloadAndInstallUpdate(
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user