From 49d6f63d4f0a8af77b236681388bfdc160f335ea Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Mon, 19 Oct 2026 23:55:19 -0500 Subject: [PATCH] fix(desktop): retry a timed-out Windows update signature check once (#21273) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge (cherry picked from commit 369826946a4271f3ba3aa447ce37a7cd2f3fb8d4) --- .../src/electron/ElectronUpdater.test.ts | 84 +++++++++++++++++++ apps/desktop/src/electron/ElectronUpdater.ts | 41 +++++++++ 2 files changed, 125 insertions(+) diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 1e005d26fdf5..f2249e3c9644 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -1,5 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import type { VerifyUpdateCodeSignature } from "electron-updater"; import { beforeEach, vi } from "vite-plus/test"; const { autoUpdaterMock } = vi.hoisted(() => ({ @@ -13,6 +14,8 @@ const { autoUpdaterMock } = vi.hoisted(() => ({ fullChangelog: false, checkForUpdates: vi.fn(() => Promise.resolve(null)), downloadUpdate: vi.fn(() => Promise.resolve([])), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + verifyUpdateCodeSignature: undefined as VerifyUpdateCodeSignature | undefined, on: vi.fn(), quitAndInstall: vi.fn(), removeListener: vi.fn(), @@ -43,6 +46,7 @@ describe("ElectronUpdater", () => { autoUpdaterMock.quitAndInstall.mockClear(); autoUpdaterMock.removeListener.mockClear(); autoUpdaterMock.setFeedURL.mockClear(); + autoUpdaterMock.verifyUpdateCodeSignature = undefined; }); it.effect("scopes updater event listeners", () => @@ -136,4 +140,84 @@ describe("ElectronUpdater", () => { assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]); }).pipe(Effect.provide(ElectronUpdater.layer)), ); + + describe("Windows update signature check", () => { + const publisherNames = ["T3 Tools Inc"]; + const installerPath = "C:\\updater\\pending\\temp-T3-Code-x64.exe"; + // execFile marks the error killed when the verifier exceeds its 20-second timeout. + const timedOut = () => + Object.assign(new Error("Command failed: powershell.exe"), { killed: true }); + + const downloadVerifyingSignature = () => { + autoUpdaterMock.downloadUpdate.mockImplementationOnce(async () => { + const rejection = await autoUpdaterMock.verifyUpdateCodeSignature!( + publisherNames, + installerPath, + ); + // NSIS accepts only null as a successful signature check. + if (rejection !== null) throw new Error(`Invalid signature: ${rejection}`); + return []; + }); + return Effect.flatMap(ElectronUpdater.ElectronUpdater, (updater) => updater.downloadUpdate); + }; + + it.effect("retries a timed-out signature check with the same verifier and installer", () => + Effect.gen(function* () { + const verify = vi + .fn() + .mockRejectedValueOnce(timedOut()) + .mockResolvedValueOnce(null); + autoUpdaterMock.verifyUpdateCodeSignature = verify; + + yield* downloadVerifyingSignature(); + + assert.deepEqual(verify.mock.calls, [ + [publisherNames, installerPath], + [publisherNames, installerPath], + ]); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("fails on invalid signatures, other errors and two timeouts on each download", () => + Effect.gen(function* () { + const cases = [ + { outcome: () => Promise.resolve("Invalid publisher"), calls: 1 }, + { outcome: () => Promise.reject(new Error("Cannot execute PowerShell")), calls: 1 }, + { outcome: () => Promise.reject(timedOut()), calls: 2 }, + ]; + for (const { outcome, calls } of cases) { + const verify = vi.fn(outcome); + autoUpdaterMock.verifyUpdateCodeSignature = verify; + + // Reusing the installed verifier must neither stack retries nor exhaust future retries. + for (let download = 1; download <= 2; download++) { + const error = yield* downloadVerifyingSignature().pipe(Effect.flip); + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); + assert.equal(verify.mock.calls.length, calls * download); + } + } + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("rejects a retry when the upstream capability probe skips verification", () => + Effect.gen(function* () { + const verify = vi + .fn() + .mockRejectedValueOnce(timedOut()) + .mockImplementationOnce(async () => { + // Upstream resolves null even though its failed capability probe skips verification. + autoUpdaterMock.logger.warn( + "Cannot execute ConvertTo-Json: spawnSync powershell.exe ETIMEDOUT. Ignoring signature validation due to unsupported powershell version. Please upgrade to powershell 3 or higher.", + ); + return null; + }); + autoUpdaterMock.verifyUpdateCodeSignature = verify; + + const error = yield* downloadVerifyingSignature().pipe(Effect.flip); + + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); + assert.equal(verify.mock.calls.length, 2); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + }); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index e42ebc1003ab..9cd357f11ca4 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { autoUpdater } from "electron-updater"; +import type { NsisUpdater, VerifyUpdateCodeSignature } from "electron-updater"; type AutoUpdater = typeof autoUpdater; @@ -80,6 +81,45 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} +const signatureChecksWithRetry = new WeakSet(); + +const retrySignatureCheckAfterTimeout = () => { + // Only the Windows NSIS updater exposes this hook; its PowerShell check times out after 20 s. + const updater = autoUpdater as AutoUpdater & + Partial>; + const verify = updater.verifyUpdateCodeSignature; + if (verify === undefined || signatureChecksWithRetry.has(verify)) return; + + const verifyWithRetry: VerifyUpdateCodeSignature = async (publisherNames, path) => { + try { + return await verify(publisherNames, path); + } catch (error) { + if (!(error instanceof Error && "killed" in error && error.killed === true)) throw error; + } + + const logger = updater.logger; + let skipped = false; + updater.logger = { + info: (message) => logger?.info(message), + warn: (message) => { + // A failed capability probe makes electron-updater warn and return null without checking. + skipped ||= String(message).includes("Ignoring signature validation"); + logger?.warn(message); + }, + error: (message) => logger?.error(message), + debug: (message) => logger?.debug?.(message), + }; + try { + const result = await verify(publisherNames, path); + return skipped ? "Signature validation was skipped after a timed-out check." : result; + } finally { + updater.logger = logger; + } + }; + signatureChecksWithRetry.add(verifyWithRetry); + updater.verifyUpdateCodeSignature = verifyWithRetry; +}; + /** @public Service construction is part of the canonical Effect module API. */ export const make = ElectronUpdater.of({ setFeedURL: (options) => @@ -131,6 +171,7 @@ export const make = ElectronUpdater.of({ }).pipe(Effect.asVoid); }), downloadUpdate: Effect.suspend(() => { + retrySignatureCheckAfterTimeout(); const channel = autoUpdater.channel; return Effect.tryPromise({ try: () => autoUpdater.downloadUpdate(),