From c77a6761afa4ecaf8be29c9de5205c1c85aa23d3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 9 Aug 2026 11:20:16 +0200 Subject: [PATCH] Treat unpublished desktop updates as unavailable (#3076) * fix(desktop): treat unpublished updates as unavailable GitHub releases can become visible before their platform updater manifests finish publishing. Treat that specific upstream condition as an unavailable update while preserving Paseo reporting for genuine updater failures. * refactor(desktop): inject updater runtime dependency Exercise updater event and rejection behavior through a typed fake at the runtime boundary instead of coordinating a hoisted module singleton in tests. * fix(desktop): preserve lazy updater initialization * revert(desktop): drop updater runtime refactor --- .../desktop/src/features/auto-updater.test.ts | 77 ++++++++++++++++++- packages/desktop/src/features/auto-updater.ts | 41 ++++++++-- 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/packages/desktop/src/features/auto-updater.test.ts b/packages/desktop/src/features/auto-updater.test.ts index 3c28e61c1..d4c42c032 100644 --- a/packages/desktop/src/features/auto-updater.test.ts +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -4,24 +4,99 @@ import path from "node:path"; import { UUID } from "builder-util-runtime"; import { describe, expect, it, vi } from "vitest"; +const { autoUpdaterMock } = vi.hoisted(() => { + const handlers = new Map void>(); + return { + autoUpdaterMock: { + handlers, + logger: { + debug: vi.fn(), + error: vi.fn((message: unknown) => console.error(message)), + info: vi.fn(), + warn: vi.fn(), + }, + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + on: vi.fn((event: string, handler: (value: unknown) => void) => { + handlers.set(event, handler); + }), + quitAndInstall: vi.fn(), + }, + }; +}); + vi.mock("electron", () => ({ app: { getPath: vi.fn(), + isPackaged: true, }, })); vi.mock("electron-updater", () => ({ - autoUpdater: {}, + autoUpdater: autoUpdaterMock, })); import { bucketFromStagingUserId, + checkForAppUpdate, resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout, shouldInstallAppUpdateOnQuit, } from "./auto-updater"; +describe("checkForAppUpdate", () => { + it("treats an unpublished channel manifest as an unavailable update", async () => { + const error = Object.assign(new Error("Cannot find latest-mac.yml"), { + code: "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", + }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + autoUpdaterMock.checkForUpdates.mockImplementationOnce(async () => { + autoUpdaterMock.logger.error(error); + autoUpdaterMock.handlers.get("error")?.(error); + throw error; + }); + + const result = await checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + expect(result).toEqual({ + hasUpdate: false, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.3", + body: null, + date: null, + errorMessage: null, + }); + expect(consoleError).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it("keeps genuine updater failures visible", async () => { + const error = new Error("network down"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + autoUpdaterMock.checkForUpdates.mockImplementationOnce(async () => { + autoUpdaterMock.logger.error(error); + autoUpdaterMock.handlers.get("error")?.(error); + throw error; + }); + + const result = await checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + expect(result.errorMessage).toBe("network down"); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); + describe("shouldInstallAppUpdateOnQuit", () => { it("keeps Linux AppImage updates on the manual install path", () => { expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: true })).toBe(false); diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index c9b9a983b..d0e5f432d 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -33,6 +33,17 @@ export { let cachedStagingUserIdPromise: Promise | null = null; +const UPDATE_CHANNEL_NOT_PUBLISHED_CODE = "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND"; + +function isUpdateChannelNotPublished(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === UPDATE_CHANNEL_NOT_PUBLISHED_CODE + ); +} + export function shouldAdmitToRollout(args: { channel: AppReleaseChannel; rolloutHours: number | undefined; @@ -109,6 +120,18 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { if (this.configured) return; this.configured = true; + // electron-updater logs every emitted error before consumers can classify it. + // Paseo reports genuine check, runtime, and install failures through the + // callbacks below, so leave internal error logging disabled to avoid both + // duplicate logs and expected missing-channel noise. + const updaterLogger = autoUpdater.logger; + autoUpdater.logger = { + debug: updaterLogger?.debug ? (message) => updaterLogger.debug?.(message) : undefined, + error: () => undefined, + info: (message) => updaterLogger?.info(message), + warn: (message) => updaterLogger?.warn(message), + }; + autoUpdater.on("update-available", (info) => { input.onUpdateAvailable(info as RuntimeUpdateInfo); }); @@ -119,17 +142,23 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { input.onUpdateNotAvailable(); }); autoUpdater.on("error", (error) => { + if (isUpdateChannelNotPublished(error)) return; input.onError(error); }); } async checkForUpdates(): Promise { - const result = await autoUpdater.checkForUpdates(); - if (!result) return null; - return { - isUpdateAvailable: result.isUpdateAvailable, - updateInfo: result.updateInfo as RuntimeUpdateInfo, - }; + try { + const result = await autoUpdater.checkForUpdates(); + if (!result) return null; + return { + isUpdateAvailable: result.isUpdateAvailable, + updateInfo: result.updateInfo as RuntimeUpdateInfo, + }; + } catch (error) { + if (isUpdateChannelNotPublished(error)) return null; + throw error; + } } downloadUpdate(): Promise {