mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(macos): ship privacy usage-description strings in the helper bundle
The detached PTY daemon keeps running inside the nested helper bundle after the main app quits, so macOS TCC treats the helper as the requesting app. The helper shipped no NS*UsageDescription keys, so consent dialogs were never offered and terminals hit silent EPERM. Merge the main app's usage strings into every nested helper Info.plist during afterPack, before code signing seals the bundles. Addresses one cause of #9756.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||
'<plist version="1.0">',
|
||||
'<dict>',
|
||||
' <key>CFBundleIdentifier</key>',
|
||||
' <string>com.stablyai.orca.helper</string>',
|
||||
'</dict>',
|
||||
'</plist>'
|
||||
].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 <socket>"); 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'),
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||
'<plist version="1.0">',
|
||||
'<dict>',
|
||||
' <key>CFBundleIdentifier</key>',
|
||||
' <string>com.stablyai.orca.helper</string>',
|
||||
'</dict>',
|
||||
'</plist>'
|
||||
].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/**'])
|
||||
|
||||
Reference in New Issue
Block a user