diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 2af549e8f51..b8f5e88936c 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -640,6 +640,7 @@ module.exports = { icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', + ...(isMacAdhoc ? { sign: resolve(__dirname, 'scripts/sign-adhoc-mac-app.mjs') } : {}), signIgnore: [ ...bundledRipgrepMacSignIgnore, ...orcadTemplateMacSignIgnore, diff --git a/config/scripts/sign-adhoc-mac-app.mjs b/config/scripts/sign-adhoc-mac-app.mjs new file mode 100644 index 00000000000..b33552f3a2b --- /dev/null +++ b/config/scripts/sign-adhoc-mac-app.mjs @@ -0,0 +1,22 @@ +import { createRequire } from 'node:module' + +export function signAdhocMacApp(options, { sign, retry }) { + // A custom signer bypasses electron-builder's retry wrapper. + return retry(() => sign({ ...options, batchCodesignCalls: true }), { + retries: 3, + interval: 5000, + backoff: 5000 + }) +} + +export default async function sign(options) { + const { sign: signApplication } = await import('@electron/osx-sign') + const builderRequire = createRequire(createRequire(import.meta.url).resolve('electron-builder')) + const require = createRequire(builderRequire.resolve('app-builder-lib')) + const { retry } = require('builder-util') + const started = performance.now() + await signAdhocMacApp(options, { sign: signApplication, retry }) + console.log( + `[mac-sign] batched signing completed in ${Math.round(performance.now() - started)}ms` + ) +} diff --git a/config/scripts/sign-adhoc-mac-app.test.mjs b/config/scripts/sign-adhoc-mac-app.test.mjs new file mode 100644 index 00000000000..893085228f6 --- /dev/null +++ b/config/scripts/sign-adhoc-mac-app.test.mjs @@ -0,0 +1,111 @@ +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { expect, it } from 'vitest' +import { signAdhocMacApp } from './sign-adhoc-mac-app.mjs' + +const require = createRequire(import.meta.url) +const builderRequire = createRequire(require.resolve('electron-builder')) +const appBuilderRequire = createRequire(builderRequire.resolve('app-builder-lib')) +const { resolveFunction } = appBuilderRequire('app-builder-lib/out/util/resolve') + +it('preserves file-specific signing policy and retries with fresh options', async () => { + const calls = [] + const failure = new Error('timestamp service unavailable') + const options = { + app: '/fixture/Orca.app', + platform: 'darwin', + type: 'distribution', + identity: 'SIGNING_IDENTITY', + identityValidation: false, + keychain: '/fixture/signing.keychain', + binaries: ['/fixture/extra-helper'], + optionsForFile: (path) => ({ entitlements: `${path}.plist`, hardenedRuntime: true }), + ignore: [(path) => path.includes('/orcad-template/')], + preAutoEntitlements: false + } + let policy + const result = await signAdhocMacApp(options, { + async retry(task, retryOptions) { + policy = retryOptions + try { + return await task() + } catch (error) { + expect(error).toBe(failure) + return task() + } + }, + async sign(received) { + calls.push(received) + if (calls.length === 1) { + throw failure + } + return 'signed' + } + }) + expect(result).toBe('signed') + expect(policy).toEqual({ retries: 3, interval: 5000, backoff: 5000 }) + expect(calls).toHaveLength(2) + expect(calls[0]).not.toBe(calls[1]) + for (const received of calls) { + expect(received).toEqual({ ...options, batchCodesignCalls: true }) + expect(received.optionsForFile).toBe(options.optionsForFile) + expect(received.optionsForFile('/fixture/helper')).toEqual({ + entitlements: '/fixture/helper.plist', + hardenedRuntime: true + }) + expect(received.ignore[0]('/fixture/orcad-template/helper')).toBe(true) + } + expect(options).not.toHaveProperty('batchCodesignCalls') +}) + +it('propagates signing failure after the retry policy is exhausted', async () => { + const failure = new Error('signature verification failed') + await expect( + signAdhocMacApp( + {}, + { + retry: (task) => task(), + sign: async () => { + throw failure + } + } + ) + ).rejects.toBe(failure) +}) + +it.each([ + { ORCA_MAC_ADHOC: '1' }, + {}, + { ORCA_MAC_RELEASE: '1' }, + { ORCA_MAC_HOURLY: '1' }, + { ORCA_MAC_DAILY: '1' }, + { ORCA_WIN_ADHOC: '1' } +])('only selects the batched signer for adhoc macOS builds: %j', async (env) => { + const configPath = resolve('config/electron-builder.config.cjs') + const saved = { ...process.env } + try { + for (const name of Object.keys(process.env)) { + if (name.startsWith('ORCA_')) { + delete process.env[name] + } + } + Object.assign(process.env, env) + delete require.cache[configPath] + const config = require(configPath) + if (env.ORCA_MAC_ADHOC === '1') { + expect(config.mac.sign).toBe(resolve('config/scripts/sign-adhoc-mac-app.mjs')) + const hook = await resolveFunction(undefined, config.mac.sign, 'sign', process.cwd()) + expect(typeof hook).toBe('function') + expect(hook.name).toBe('sign') + expect(config.mac.signIgnore).toContain('/orcad-template/') + expect(config.forceCodeSigning).toBe(true) + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.mac.notarize).toBe(true) + } else { + expect(config.mac.sign).toBeUndefined() + } + } finally { + process.env = saved + delete require.cache[configPath] + } +}) diff --git a/package.json b/package.json index 67683baeeb9..df73f137975 100644 --- a/package.json +++ b/package.json @@ -226,6 +226,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@electron-toolkit/tsconfig": "^2.0.0", + "@electron/osx-sign": "2.7.0", "@electron/rebuild": "^4.2.1", "@monaco-editor/react": "^4.7.0", "@noble/hashes": "1.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dec670dc93f..c2a029c1e64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,6 +287,9 @@ importers: '@electron-toolkit/tsconfig': specifier: ^2.0.0 version: 2.0.0(@types/node@24.19.2) + '@electron/osx-sign': + specifier: 2.7.0 + version: 2.7.0(supports-color@7.2.0) '@electron/rebuild': specifier: ^4.2.1 version: 4.2.1(supports-color@7.2.0) @@ -1041,6 +1044,11 @@ packages: engines: {node: '>=12.0.0'} hasBin: true + '@electron/osx-sign@2.7.0': + resolution: {integrity: sha512-9DGhNqKMl6ibkhUoXbN7OHX2gZznfY10L3ZwG0u6r667Kfb6kec4JEfFTXftoqzmOfZ+OzwDbr4p/nKBMHnz0g==} + engines: {node: '>=22.12.0'} + hasBin: true + '@electron/rebuild@4.2.1': resolution: {integrity: sha512-IS4pG3BysRrCLJ8xTAfvg+IQDbiUoAzdJy2SfjcYKnLDy5DpCdjnPdhS5Zf5UMfrkB24xu0KC+TqcOxg+eG+6w==} engines: {node: '>=22.12.0'} @@ -8944,6 +8952,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@electron/osx-sign@2.7.0(supports-color@7.2.0)': + dependencies: + debug: 4.4.3(supports-color@7.2.0) + isbinaryfile: 4.0.10 + plist: 3.1.0 + semver: 7.8.1 + transitivePeerDependencies: + - supports-color + '@electron/rebuild@4.2.1(supports-color@7.2.0)': dependencies: '@malept/cross-spawn-promise': 2.0.0