Files
orca/config/scripts/desktop-distribution/macos/build-mac-notarization-overlap.mjs
Jinwoo Hong fde1ae70da fix(mac): give Orca's main executable its own UUID so Local Network access works (#41676)
* fix(mac): give Orca's main executable its own Mach-O UUID

macOS Local Network privacy identifies an app by its main executable's
UUID. The packaged executable is stock Electron's, so it shares that UUID
with every other app on the same Electron version and macOS can apply
another app's Local Network decision to Orca. Rewrite LC_UUID in every
slice to a value derived from the app id, executable name and CPU type,
before signing.

* fix(mac): re-sign rewritten executables ad hoc, cover the terminal host, assert UUIDs at release

Rewriting LC_UUID breaks Electron's linker ad hoc signature, and arm64
kills unsigned code, so identity-less local builds (where app signing is
skipped) produced an Orca.app that died at launch. Re-sign the rewritten
executable ad hoc, outside the bundle so codesign does not treat it as
the bundle; real signing replaces it later.

The terminal host's executable is a stock Node binary whose UUID is
shared by every copy of that Node release, so give it its own stable
UUID the same way. Packaging and release verification now fail when a
shipped executable does not carry its expected UUID.

* fix(mac): scope the executable UUID rewrite to the main app

The terminal host gets a separate design; leave it unchanged here.
2026-11-13 04:53:48 -05:00

168 lines
5.8 KiB
JavaScript

import { execFileSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { basename, join, resolve } from 'node:path'
import { assertMacExecutableUuid } from './macos-executable-uuid.cjs'
const ARCHITECTURES = [1, 3] // electron-builder: x64, arm64.
export async function buildWithOverlappingNotarization({
config,
build,
notarize,
verify,
targets,
publish,
credentials,
log = console.log
}) {
if (!config.forceCodeSigning || !config.mac?.hardenedRuntime || config.mac.notarize !== true) {
throw new Error('Overlapping notarization requires the complete macOS release signing policy')
}
for (const hook of ['afterSign', 'artifactBuildStarted']) {
if (config[hook] !== undefined && typeof config[hook] !== 'function') {
throw new Error(`Unsupported ${hook} hook: expected a function`)
}
}
const tasks = new Map()
const paths = new Set()
let aborted = false
let resolvePrepared
let rejectPrepared
const prepared = new Promise((resolve, reject) => {
resolvePrepared = resolve
rejectPrepared = reject
})
// The first target can be waiting while the second architecture is still signing.
prepared.catch(() => {})
const waitForNotarization = async () => {
await prepared
const results = await Promise.all(tasks.values())
const failure = results.find((result) => 'error' in result)
if (failure) {
throw failure.error
}
if (aborted) {
throw new Error('macOS packaging was aborted')
}
}
try {
const artifacts = await build({
targets,
publish,
config: {
...config,
// Only this invocation replaces builder's blocking notary wait with the guarded hooks.
mac: { ...config.mac, notarize: false },
concurrency: { ...config.concurrency, jobs: 1 },
afterSign: async (context) => {
await config.afterSign?.(context)
if (context.electronPlatformName !== 'darwin' || !ARCHITECTURES.includes(context.arch)) {
throw new Error('Unexpected platform or architecture in macOS signing hook')
}
const appPath = join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
if (tasks.has(context.arch) || paths.has(appPath)) {
throw new Error('macOS architectures must have separate, freshly signed app bundles')
}
paths.add(appPath)
const task = Promise.resolve()
.then(async () => {
log(`[mac-notary] submitting ${appPath}`)
await notarize({ appPath, ...credentials })
await verify(appPath)
log(`[mac-notary] accepted, stapled and verified ${appPath}`)
return {}
})
.catch((error) => {
rejectPrepared(error)
return { error }
})
tasks.set(context.arch, task)
if (tasks.size === ARCHITECTURES.length) {
resolvePrepared()
}
},
artifactBuildStarted: async (event) => {
if (!ARCHITECTURES.includes(event.arch)) {
throw new Error('Unexpected architecture in macOS artifact hook')
}
await waitForNotarization()
await config.artifactBuildStarted?.(event)
}
}
})
if (tasks.size !== ARCHITECTURES.length) {
throw new Error('Both macOS architectures must be signed before artifacts can succeed')
}
await waitForNotarization()
return artifacts
} catch (error) {
aborted = true
rejectPrepared(error)
throw error
} finally {
// Drain outstanding notary work before a failed invocation returns to retry.
await Promise.all(tasks.values())
}
}
export async function main(args = process.argv.slice(2)) {
if (process.platform !== 'darwin') {
throw new Error('macOS release packaging requires macOS')
}
const publish = args[0] === '--publish' && ['always', 'never'].includes(args[1]) ? args[1] : null
if (!publish || args.slice(2).some((arg) => arg !== '--draft') || args.length > 3) {
throw new Error('Usage: build-mac-notarization-overlap.mjs --publish always|never [--draft]')
}
const require = createRequire(import.meta.url)
const { build, Platform, Arch } = require('electron-builder')
// Use the same pinned notarization implementation as electron-builder, including its retries.
const builderRequire = createRequire(require.resolve('electron-builder'))
const { notarize } = createRequire(builderRequire.resolve('app-builder-lib'))(
'@electron/notarize'
)
const config = require('../../../electron-builder.config.cjs')
if (args.includes('--draft')) {
config.publish = { ...config.publish, releaseType: 'draft' }
}
const credentials = {
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
teamId: process.env.APPLE_TEAM_ID
}
if (Object.values(credentials).some((value) => !value?.trim())) {
throw new Error('macOS notarization credentials are incomplete')
}
return buildWithOverlappingNotarization({
config,
build,
notarize,
verify: (appPath) => {
execFileSync('codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'inherit' })
execFileSync('xcrun', ['stapler', 'validate', appPath], { stdio: 'inherit' })
assertMacExecutableUuid(
join(appPath, 'Contents', 'MacOS', basename(appPath, '.app')),
config.appId
)
},
targets: new Map([
[
Platform.MAC,
new Map([
[Arch.x64, ['dmg', 'zip']],
[Arch.arm64, ['dmg', 'zip']]
])
]
]),
publish,
credentials
})
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
main().catch((error) => {
console.error(error)
// electron-builder's temp-file exit hook calls process.exit(0) on beforeExit, which discards exitCode.
process.exit(1)
})
}