diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts new file mode 100644 index 000000000000..edec500f97ac --- /dev/null +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -0,0 +1,108 @@ +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(() => ({ + autoUpdaterMock: { + channel: "latest", + downloadUpdate: async (): Promise => [], + logger: { + info: (_message: unknown) => {}, + warn: (_message: unknown) => {}, + error: (_message: unknown) => {}, + }, + verifyUpdateCodeSignature: undefined as VerifyUpdateCodeSignature | undefined, + }, +})); + +vi.mock("electron-updater", () => ({ autoUpdater: autoUpdaterMock })); + +import * as ElectronUpdater from "./ElectronUpdater.ts"; + +describe("Windows update signature check", () => { + const publisherNames = ["T3 Tools Inc"]; + const installerPath = "C:\\updater\\pending\\temp-T3-Code-x64.exe"; + // execFile marks a timeout kill; no clock or PowerShell process is needed here. + const timedOut = () => + Object.assign(new Error("Command failed: powershell.exe"), { killed: true }); + + beforeEach(() => { + autoUpdaterMock.verifyUpdateCodeSignature = undefined; + autoUpdaterMock.downloadUpdate = async () => { + const rejection = await autoUpdaterMock.verifyUpdateCodeSignature!( + publisherNames, + installerPath, + ); + if (rejection !== null) throw new Error(`Invalid signature: ${rejection}`); + return [installerPath]; + }; + }); + + const downloadVerifyingSignature = () => + Effect.flatMap(ElectronUpdater.ElectronUpdater, (updater) => updater.downloadUpdate); + + it.effect( + "downloads after a timeout only when the same installer and publisher pass verification", + () => + Effect.gen(function* () { + let firstCheck = true; + autoUpdaterMock.verifyUpdateCodeSignature = async (names, path) => { + if (path !== installerPath || names.length !== 1 || names[0] !== publisherNames[0]) { + return "Unexpected installer or publisher"; + } + if (firstCheck) { + firstCheck = false; + throw timedOut(); + } + return null; + }; + + assert.isUndefined(yield* downloadVerifyingSignature()); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("rejects invalid signatures, other errors and two timeouts on repeated downloads", () => + Effect.gen(function* () { + const outcomes = [ + () => Promise.resolve("Invalid publisher"), + () => Promise.reject(new Error("Cannot execute PowerShell")), + () => Promise.reject(timedOut()), + ]; + for (const outcome of outcomes) { + let remainingFailures = 0; + autoUpdaterMock.verifyUpdateCodeSignature = () => + remainingFailures-- > 0 ? outcome() : Promise.resolve(null); + + // An extra retry would accept the installer, including if wrappers stack on download two. + for (let download = 0; download < 2; download++) { + remainingFailures = outcome === outcomes[2] ? 2 : 1; + const error = yield* downloadVerifyingSignature().pipe(Effect.flip); + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); + } + } + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("rejects a retry when the upstream capability probe skips verification", () => + Effect.gen(function* () { + let firstCheck = true; + autoUpdaterMock.verifyUpdateCodeSignature = async () => { + if (firstCheck) { + firstCheck = false; + throw timedOut(); + } + // electron-updater resolves null after this warning despite skipping validation. + 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; + }; + + const error = yield* downloadVerifyingSignature().pipe(Effect.flip); + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); + assert.instanceOf(error.cause, Error); + assert.include(error.cause.message, "Signature validation was skipped"); + }).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(),