diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 50cfe7691e8..e098612fdd2 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -11,6 +11,10 @@ const { prunePackagedRuntimeNodeModules, verifyPackagedMainRuntimeDeps } = require('./packaged-runtime-node-modules.cjs') +const { + extendMacHelperBundleInfoPlists, + macPrivacyUsageDescriptions +} = require('./mac-privacy-usage-descriptions.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' @@ -177,6 +181,12 @@ module.exports = { chmodSync(join(resourcesDir, filename), 0o755) } if (context.electronPlatformName === 'darwin') { + // Why: the detached PTY daemon outlives the main app inside the Electron + // helper bundle, which TCC then treats as the requesting app (#9756). + // afterPack runs before electron-builder signs, so the edits get sealed. + extendMacHelperBundleInfoPlists( + join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`) + ) await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager) await signMacNotificationStatusHelper( join(resourcesDir, '..', 'MacOS', 'orca-notification-status'), @@ -229,25 +239,8 @@ module.exports = { entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', extendInfo: { - NSAppleEventsUsageDescription: - 'Orca allows terminal-launched developer tools to automate local apps when you request it.', - NSBluetoothAlwaysUsageDescription: - 'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.', - NSBluetoothPeripheralUsageDescription: - 'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.', - NSCameraUsageDescription: "Application requests access to the device's camera.", - NSLocationUsageDescription: - 'Orca allows terminal-launched developer tools to access location when you request it.', - NSLocalNetworkUsageDescription: - 'Orca allows terminal-launched developer tools to discover and connect to local development servers when you request it.', - NSMicrophoneUsageDescription: "Application requests access to the device's microphone.", - NSAudioCaptureUsageDescription: - 'Orca allows terminal-launched developer tools to capture desktop audio when you request it.', - NSBonjourServices: ['_http._tcp', '_https._tcp'], - NSDocumentsFolderUsageDescription: - "Application requests access to the user's Documents folder.", - NSDownloadsFolderUsageDescription: - "Application requests access to the user's Downloads folder." + ...macPrivacyUsageDescriptions, + NSBonjourServices: ['_http._tcp', '_https._tcp'] }, // Why: local macOS validation builds should launch without Apple release // credentials. Hardened runtime + notarization stay enabled only on the diff --git a/config/mac-privacy-usage-descriptions.cjs b/config/mac-privacy-usage-descriptions.cjs new file mode 100644 index 00000000000..c14e1fabcd9 --- /dev/null +++ b/config/mac-privacy-usage-descriptions.cjs @@ -0,0 +1,54 @@ +const { execFileSync } = require('node:child_process') +const { existsSync, readdirSync } = require('node:fs') +const { join } = require('node:path') + +// Why: macOS shows a consent dialog only when the requesting bundle declares a +// usage description for the service. The detached PTY daemon keeps running +// inside the Electron helper bundle after the main app quits, so the helper is +// the effective requesting app and needs the same strings as the main app — +// otherwise TCC denies with a silent EPERM and no dialog (#9756). +const macPrivacyUsageDescriptions = { + NSAppleEventsUsageDescription: + 'Orca allows terminal-launched developer tools to automate local apps when you request it.', + NSBluetoothAlwaysUsageDescription: + 'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.', + NSBluetoothPeripheralUsageDescription: + 'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.', + NSCameraUsageDescription: "Application requests access to the device's camera.", + NSLocationUsageDescription: + 'Orca allows terminal-launched developer tools to access location when you request it.', + NSLocalNetworkUsageDescription: + 'Orca allows terminal-launched developer tools to discover and connect to local development servers when you request it.', + NSMicrophoneUsageDescription: "Application requests access to the device's microphone.", + NSAudioCaptureUsageDescription: + 'Orca allows terminal-launched developer tools to capture desktop audio when you request it.', + NSDocumentsFolderUsageDescription: "Application requests access to the user's Documents folder.", + NSDownloadsFolderUsageDescription: "Application requests access to the user's Downloads folder." +} + +function extendMacHelperBundleInfoPlists(appPath) { + const frameworksDir = join(appPath, 'Contents', 'Frameworks') + const helperPlistPaths = existsSync(frameworksDir) + ? readdirSync(frameworksDir) + .filter((entry) => entry.includes(' Helper') && entry.endsWith('.app')) + .map((entry) => join(frameworksDir, entry, 'Contents', 'Info.plist')) + .filter((plistPath) => existsSync(plistPath)) + : [] + if (helperPlistPaths.length === 0) { + // Why: a darwin package always nests Electron helper apps; an empty match + // means a layout drift that would silently drop the TCC strings again. + throw new Error(`No Electron helper app Info.plist found under ${frameworksDir}`) + } + for (const plistPath of helperPlistPaths) { + for (const [key, value] of Object.entries(macPrivacyUsageDescriptions)) { + // Why: plutil -replace inserts the key when absent, so this is a merge. + execFileSync('plutil', ['-replace', key, '-string', value, plistPath]) + } + } + return helperPlistPaths +} + +module.exports = { + macPrivacyUsageDescriptions, + extendMacHelperBundleInfoPlists +} diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3e16b20e26a..bb5ba182759 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' @@ -7,6 +8,10 @@ import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs') +const { + extendMacHelperBundleInfoPlists, + macPrivacyUsageDescriptions +} = require('../mac-privacy-usage-descriptions.cjs') const { createPackagedRuntimeNodeModuleResources, findAsarEntry, @@ -93,6 +98,141 @@ describe('electron-builder config', () => { ) }) + // Why: the detached PTY daemon runs inside the Electron helper bundle, so the + // helper needs the same TCC usage descriptions as the main app (#9756). + it('keeps the main-app extendInfo privacy strings in the shared helper-bundle map', () => { + expect(electronBuilderConfig.mac.extendInfo).toMatchObject(macPrivacyUsageDescriptions) + const extendInfoUsageKeys = Object.keys(electronBuilderConfig.mac.extendInfo).filter((key) => + key.endsWith('UsageDescription') + ) + expect(extendInfoUsageKeys.sort()).toEqual(Object.keys(macPrivacyUsageDescriptions).sort()) + }) + + it('fails loudly when a darwin package carries no helper bundles', async () => { + const appPath = await mkdtemp(join(tmpdir(), 'orca-no-helper-bundles-')) + try { + await mkdir(join(appPath, 'Contents', 'Frameworks'), { recursive: true }) + expect(() => extendMacHelperBundleInfoPlists(appPath)).toThrow( + /No Electron helper app Info\.plist/ + ) + } finally { + await rm(appPath, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform !== 'darwin')( + 'merges privacy usage descriptions into every helper bundle Info.plist', + async () => { + const appPath = await mkdtemp(join(tmpdir(), 'orca-helper-usage-strings-')) + try { + const helperNames = ['Orca Helper.app', 'Orca Helper (Renderer).app'] + for (const helperName of helperNames) { + const helperContents = join(appPath, 'Contents', 'Frameworks', helperName, 'Contents') + await mkdir(helperContents, { recursive: true }) + await writeFile( + join(helperContents, 'Info.plist'), + [ + '', + '', + '', + '', + ' CFBundleIdentifier', + ' com.stablyai.orca.helper', + '', + '' + ].join('\n'), + 'utf8' + ) + } + + const updatedPlists = extendMacHelperBundleInfoPlists(appPath) + + expect(updatedPlists).toHaveLength(helperNames.length) + for (const helperName of helperNames) { + const plistJson = JSON.parse( + execFileSync( + 'plutil', + [ + '-convert', + 'json', + '-o', + '-', + join(appPath, 'Contents', 'Frameworks', helperName, 'Contents', 'Info.plist') + ], + { encoding: 'utf8' } + ) + ) + expect(plistJson).toMatchObject(macPrivacyUsageDescriptions) + expect(plistJson.CFBundleIdentifier).toBe('com.stablyai.orca.helper') + } + } finally { + await rm(appPath, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform !== 'darwin')( + 'extends helper bundle plists from the darwin afterPack hook', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-darwin-after-pack-')) + try { + const appPath = join(root, 'Orca.app') + const resourcesDir = join(appPath, 'Contents', 'Resources') + const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + await mkdir(unpackedMainDir, { recursive: true }) + await writeFile( + join(unpackedMainDir, 'daemon-entry.js'), + 'console.error("Usage: daemon-entry "); process.exit(1)\n', + 'utf8' + ) + const helperContents = join( + appPath, + 'Contents', + 'Frameworks', + 'Orca Helper.app', + 'Contents' + ) + await mkdir(helperContents, { recursive: true }) + await writeFile( + join(helperContents, 'Info.plist'), + [ + '', + '', + '', + '', + ' CFBundleIdentifier', + ' com.stablyai.orca.helper', + '', + '' + ].join('\n'), + 'utf8' + ) + + await electronBuilderConfig.afterPack({ + appOutDir: root, + electronPlatformName: 'darwin', + // Why: Arch.ia32 never matches a mac packaging host, so afterPack + // takes the existence-only daemon-entry path instead of booting it. + arch: 0, + packager: { appInfo: { productFilename: 'Orca' } } + }) + + const plistJson = JSON.parse( + execFileSync( + 'plutil', + ['-convert', 'json', '-o', '-', join(helperContents, 'Info.plist')], + { + encoding: 'utf8' + } + ) + ) + expect(plistJson).toMatchObject(macPrivacyUsageDescriptions) + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) + it('unpacks the compiled CommonJS boundary with CLI runtime files', () => { expect(electronBuilderConfig.asarUnpack).toEqual( expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])