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/**'])