From 4bbfa35563e3a32e89a29bba6fb0f223f2c7efcc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:50:10 -0700 Subject: [PATCH] fix(macos): isolate all local package identities --- config/scripts/build-native-for-platform.mjs | 24 ++++-- .../scripts/electron-builder-config.test.mjs | 26 +++++++ ...package-electron-runtime-contract.test.mjs | 4 +- package.json | 4 +- src/main/ipc/notifications.test.ts | 73 +++++++++++++++++-- src/main/ipc/notifications.ts | 25 ++++++- 6 files changed, 140 insertions(+), 16 deletions(-) diff --git a/config/scripts/build-native-for-platform.mjs b/config/scripts/build-native-for-platform.mjs index 52900a0363b..8b4f756807f 100755 --- a/config/scripts/build-native-for-platform.mjs +++ b/config/scripts/build-native-for-platform.mjs @@ -2,6 +2,9 @@ import { spawnSync } from 'node:child_process' +const isLocalMacPackage = process.argv.includes('--local-macos-package') +const localMacAppBundleId = 'com.stablyai.orca.local' + if (process.platform === 'win32') { runNodeScript('config/scripts/build-windows-cli-launcher.mjs') process.exit(0) @@ -12,19 +15,30 @@ if (process.platform !== 'darwin') { process.exit(0) } -runPnpmScript('build:computer-macos') -runPnpmScript('build:notification-status-macos') +runPnpmScript( + 'build:computer-macos', + [], + isLocalMacPackage + ? { ...process.env, ORCA_COMPUTER_MACOS_BUNDLE_ID: `${localMacAppBundleId}.computer-use` } + : process.env +) +runPnpmScript( + 'build:notification-status-macos', + isLocalMacPackage ? ['--bundle-id', localMacAppBundleId] : [] +) process.exit(0) -function runPnpmScript(scriptName) { +function runPnpmScript(scriptName, scriptArgs = [], env = process.env) { const npmExecPath = process.env.npm_execpath const command = npmExecPath ? process.execPath : process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' - const args = npmExecPath ? [npmExecPath, 'run', scriptName] : ['run', scriptName] - const result = spawnSync(command, args, { stdio: 'inherit' }) + const args = npmExecPath + ? [npmExecPath, 'run', scriptName, ...scriptArgs] + : ['run', scriptName, ...scriptArgs] + const result = spawnSync(command, args, { env, stdio: 'inherit' }) if (result.signal) { process.kill(process.pid, result.signal) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3e16b20e26a..c4db7ddd50d 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -19,6 +19,32 @@ const { } = require('../packaged-runtime-node-modules.cjs') describe('electron-builder config', () => { + it('changes only the non-release macOS app id', () => { + const configPath = require.resolve('../electron-builder.config.cjs') + const original = process.env.ORCA_MAC_RELEASE + try { + delete require.cache[configPath] + delete process.env.ORCA_MAC_RELEASE + const localConfig = require('../electron-builder.config.cjs') + expect(localConfig.appId).toBe('com.stablyai.orca') + expect(localConfig.mac.appId).toBe('com.stablyai.orca.local') + + delete require.cache[configPath] + process.env.ORCA_MAC_RELEASE = '1' + const releaseConfig = require('../electron-builder.config.cjs') + expect(releaseConfig.appId).toBe('com.stablyai.orca') + expect(releaseConfig.mac.appId).toBeUndefined() + } finally { + if (original === undefined) { + delete process.env.ORCA_MAC_RELEASE + } else { + process.env.ORCA_MAC_RELEASE = original + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } + }) + it('excludes repo-only source trees from app.asar', () => { expect(electronBuilderConfig.files).toEqual( expect.arrayContaining([ diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 97bbf5c7ae8..ec9f511e5ae 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -99,7 +99,9 @@ describe('Electron runtime package contract', () => { expect(scripts['build:linux']).toContain('pnpm run build:desktop') expect(scripts['build:linux']).not.toContain('pnpm run build ') expect(scripts['build:linux']).not.toContain('build:computer-macos') - expect(scripts['build:mac']).toContain('pnpm run build:computer-macos') + expect(scripts['build:unpack']).toContain('pnpm run build:native -- --local-macos-package') + expect(scripts['build:mac']).toContain('pnpm run build:native -- --local-macos-package') + expect(scripts['build:mac:release']).not.toContain('com.stablyai.orca.local') expect(scripts['build:release']).toContain('pnpm run build:native') expect(scripts['build:release']).not.toContain('build:computer-macos') }) diff --git a/package.json b/package.json index 6bff416d0c2..0aa0c585ea5 100644 --- a/package.json +++ b/package.json @@ -71,10 +71,10 @@ "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "rebuild:node": "pnpm rebuild node-pty", - "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", + "build:unpack": "pnpm run build:desktop && pnpm run build:native -- --local-macos-package && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", - "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos --bundle-id com.stablyai.orca.local && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac": "pnpm run build:desktop && pnpm run build:native -- --local-macos-package && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless", diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index a194e6e295f..3e819ba354c 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -15,7 +15,9 @@ const { notificationCtorMock, notificationIsSupportedMock, getAllWindowsMock, - shellOpenExternalMock + shellOpenExternalMock, + electronAppMock, + execFileSyncMock } = vi.hoisted(() => { const removeHandlerMock = vi.fn() const handleMock = vi.fn() @@ -36,6 +38,12 @@ const { const notificationIsSupportedMock = vi.fn(() => true) const getAllWindowsMock = vi.fn(() => []) const shellOpenExternalMock = vi.fn() + const electronAppMock = { + focus: vi.fn(), + getPath: vi.fn(), + isPackaged: false + } + const execFileSyncMock = vi.fn() return { removeHandlerMock, handleMock, @@ -47,10 +55,14 @@ const { notificationCtorMock, notificationIsSupportedMock, getAllWindowsMock, - shellOpenExternalMock + shellOpenExternalMock, + electronAppMock, + execFileSyncMock } }) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) + vi.mock('electron', () => ({ ipcMain: { removeHandler: removeHandlerMock, @@ -62,9 +74,7 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: getAllWindowsMock }, - app: { - focus: vi.fn() - }, + app: electronAppMock, shell: { openExternal: shellOpenExternalMock } @@ -122,6 +132,10 @@ describe('registerNotificationHandlers', () => { getAllWindowsMock.mockReset() getAllWindowsMock.mockReturnValue([]) shellOpenExternalMock.mockClear() + electronAppMock.focus.mockClear() + electronAppMock.getPath.mockReset() + electronAppMock.isPackaged = false + execFileSyncMock.mockReset() setTrayAttentionMock.mockClear() }) @@ -278,6 +292,55 @@ describe('registerNotificationHandlers', () => { } }) + it('reads the packaged bundle id when launchd does not provide one', async () => { + const originalPlatform = process.platform + const originalDevBundleId = process.env.ORCA_DEV_MACOS_BUNDLE_ID + const originalLaunchdBundleId = process.env.__CFBundleIdentifier + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + delete process.env.ORCA_DEV_MACOS_BUNDLE_ID + delete process.env.__CFBundleIdentifier + electronAppMock.isPackaged = true + electronAppMock.getPath.mockReturnValue('/Applications/Orca.app/Contents/MacOS/Orca') + execFileSyncMock.mockReturnValue('com.stablyai.orca.local\n') + try { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getOpenSystemSettingsHandler() + handler({}) + + expect(execFileSyncMock).toHaveBeenCalledWith( + '/usr/libexec/PlistBuddy', + ['-c', 'Print :CFBundleIdentifier', '/Applications/Orca.app/Contents/Info.plist'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + ) + expect(shellOpenExternalMock).toHaveBeenCalledWith( + 'x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=com.stablyai.orca.local' + ) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + electronAppMock.isPackaged = false + if (originalDevBundleId === undefined) { + delete process.env.ORCA_DEV_MACOS_BUNDLE_ID + } else { + process.env.ORCA_DEV_MACOS_BUNDLE_ID = originalDevBundleId + } + if (originalLaunchdBundleId === undefined) { + delete process.env.__CFBundleIdentifier + } else { + process.env.__CFBundleIdentifier = originalLaunchdBundleId + } + } + }) + it('opens Windows notification settings', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index bd308cea85b..6478f3bc629 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -1,7 +1,8 @@ /* eslint-disable max-lines -- Why: notification IPC keeps permission, dispatch, custom sound asset, and sound-loading handlers colocated so renderer/main contracts stay auditable. */ import { app, BrowserWindow, Notification, ipcMain, shell } from 'electron' +import { execFileSync } from 'node:child_process' import { readFile, stat } from 'node:fs/promises' -import { extname, isAbsolute, normalize } from 'node:path' +import { dirname, extname, isAbsolute, join, normalize } from 'node:path' import beepSoundPath from '../../../resources/notification-sounds/beep.mp3?asset' import blipSoundPath from '../../../resources/notification-sounds/blip.mp3?asset' import blopSoundPath from '../../../resources/notification-sounds/blop.mp3?asset' @@ -186,11 +187,29 @@ function probeNotificationDelivery(): Promise { return deliveryProbeInFlight } +function readPackagedMacBundleId(): string | null { + if (!app.isPackaged) { + return null + } + try { + const infoPlistPath = join(dirname(app.getPath('exe')), '..', 'Info.plist') + return ( + execFileSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', infoPlistPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() || null + ) + } catch { + return null + } +} + function getMacNotificationSettingsUrl(): string { - // Why: local packaging uses com.stablyai.orca.local (#9756), so trust the - // launchd-provided id before falling back to the release id. + // Why: direct executable launches do not reliably receive launchd's private + // bundle-id environment variable, so packaged apps read their own plist. const bundleId = process.env.ORCA_DEV_MACOS_BUNDLE_ID ?? + readPackagedMacBundleId() ?? process.env.__CFBundleIdentifier ?? MACOS_PACKAGED_BUNDLE_ID return `${MACOS_NOTIFICATION_SETTINGS_URL}?id=${encodeURIComponent(bundleId)}`