mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 16:02:38 +00:00
* fix(permissions): surface macOS silent Local Network denial with diagnostic and workaround (STA-3505) On macOS 27 beta, NECP silently denies Orca's whole process tree Local Network access: no prompt fires, the app never appears in System Settings, and terminal child processes fail with EHOSTUNREACH. The Settings trigger swallowed the probe's socket error and reported 'unknown' + a 'Permission request sent' toast, indistinguishable from success. Classify the mDNS probe outcome (EHOSTUNREACH/EHOSTDOWN -> denied, clean send -> granted, else unknown), remember the verdict for the status chip, and render an inline diagnostic with the documented NECP re-evaluation workaround when denial is detected. * fix(permissions): avoid false Local Network grants * fix(permissions): use standard Local Network request flow * feat(permissions): add local network connection test * fix(permissions): nest local network connection test * fix(permissions): collapse connection test by default * fix(permissions): emphasize connection test action * fix(permissions): restore outlined connection action
248 lines
7.8 KiB
TypeScript
248 lines
7.8 KiB
TypeScript
import { execFile } from 'node:child_process'
|
|
import dgram from 'node:dgram'
|
|
import { ipcMain, shell, systemPreferences } from 'electron'
|
|
import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status'
|
|
import { testLocalNetworkConnection } from './local-network-connection-test'
|
|
import type {
|
|
DeveloperPermissionId,
|
|
DeveloperPermissionRequestResult,
|
|
DeveloperPermissionState,
|
|
DeveloperPermissionStatus
|
|
} from '../../shared/developer-permissions-types'
|
|
|
|
const PRIVACY_PANE_URLS: Partial<Record<DeveloperPermissionId, string>> = {
|
|
camera: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera',
|
|
microphone: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
|
|
screen: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
|
accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility',
|
|
'full-disk-access': 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
|
|
automation: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation',
|
|
'local-network':
|
|
'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_LocalNetwork',
|
|
bluetooth: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth'
|
|
}
|
|
|
|
const DEVELOPER_PERMISSION_IDS: DeveloperPermissionId[] = [
|
|
'microphone',
|
|
'camera',
|
|
'screen',
|
|
'accessibility',
|
|
'full-disk-access',
|
|
'automation',
|
|
'local-network',
|
|
'usb',
|
|
'bluetooth'
|
|
]
|
|
const APPLE_EVENTS_PROMPT_TIMEOUT_MS = 3_000
|
|
|
|
function unsupportedOffMac(): DeveloperPermissionStatus | null {
|
|
return process.platform === 'darwin' ? null : 'unsupported'
|
|
}
|
|
|
|
function getMediaStatus(mediaType: 'microphone' | 'camera' | 'screen'): DeveloperPermissionStatus {
|
|
const unsupported = unsupportedOffMac()
|
|
if (unsupported) {
|
|
return unsupported
|
|
}
|
|
try {
|
|
return systemPreferences.getMediaAccessStatus(mediaType)
|
|
} catch {
|
|
return 'unknown'
|
|
}
|
|
}
|
|
|
|
function getAccessibilityStatus(): DeveloperPermissionStatus {
|
|
const unsupported = unsupportedOffMac()
|
|
if (unsupported) {
|
|
return unsupported
|
|
}
|
|
return systemPreferences.isTrustedAccessibilityClient(false) ? 'granted' : 'unknown'
|
|
}
|
|
|
|
async function openPrivacyPane(id: DeveloperPermissionId): Promise<boolean> {
|
|
const url = PRIVACY_PANE_URLS[id]
|
|
if (!url) {
|
|
await shell.openExternal(
|
|
'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension'
|
|
)
|
|
return true
|
|
}
|
|
await shell.openExternal(url)
|
|
return true
|
|
}
|
|
|
|
function triggerAppleEventsPrompt(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
let child: ReturnType<typeof execFile> | null = null
|
|
let settled = false
|
|
|
|
const finish = (): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(timeout)
|
|
resolve()
|
|
}
|
|
|
|
// Why: this request only nudges macOS to show the Automation prompt; a
|
|
// stuck osascript process should not keep the permission IPC pending.
|
|
const timeout = setTimeout(() => {
|
|
child?.kill()
|
|
finish()
|
|
}, APPLE_EVENTS_PROMPT_TIMEOUT_MS)
|
|
if (typeof timeout.unref === 'function') {
|
|
timeout.unref()
|
|
}
|
|
|
|
try {
|
|
child = execFile(
|
|
'osascript',
|
|
['-e', 'tell application "System Events" to return 1'],
|
|
{ timeout: APPLE_EVENTS_PROMPT_TIMEOUT_MS },
|
|
finish
|
|
)
|
|
} catch {
|
|
finish()
|
|
}
|
|
})
|
|
}
|
|
|
|
function triggerLocalNetworkPrompt(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
let settled = false
|
|
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
function finish(): void {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
socket.removeListener('error', finish)
|
|
if (timeout) {
|
|
clearTimeout(timeout)
|
|
timeout = null
|
|
}
|
|
try {
|
|
socket.close()
|
|
} catch {
|
|
// Already closed or never fully bound.
|
|
}
|
|
resolve()
|
|
}
|
|
socket.on('error', finish)
|
|
socket.bind(() => {
|
|
const message = Buffer.from([0])
|
|
socket.send(message, 0, message.length, 5353, '224.0.0.251', finish)
|
|
})
|
|
timeout = setTimeout(finish, 1000)
|
|
if (typeof timeout.unref === 'function') {
|
|
timeout.unref()
|
|
}
|
|
})
|
|
}
|
|
|
|
async function getPermissionState(id: DeveloperPermissionId): Promise<DeveloperPermissionState> {
|
|
switch (id) {
|
|
case 'microphone':
|
|
case 'camera':
|
|
case 'screen':
|
|
return { id, status: getMediaStatus(id) }
|
|
case 'accessibility':
|
|
return { id, status: getAccessibilityStatus() }
|
|
case 'full-disk-access':
|
|
return { id, status: await getMacosFullDiskAccessStatus() }
|
|
case 'automation':
|
|
case 'local-network':
|
|
return { id, status: unsupportedOffMac() ?? 'unknown' }
|
|
case 'usb':
|
|
case 'bluetooth':
|
|
return { id, status: unsupportedOffMac() ?? 'ready' }
|
|
}
|
|
}
|
|
|
|
async function requestPermission(
|
|
id: DeveloperPermissionId
|
|
): Promise<DeveloperPermissionRequestResult> {
|
|
if (process.platform !== 'darwin') {
|
|
return { id, status: 'unsupported', openedSystemSettings: false }
|
|
}
|
|
|
|
if (id === 'microphone' || id === 'camera') {
|
|
// Why: askForMediaAccess only surfaces the TCC prompt when status is
|
|
// 'not-determined'. If the user previously denied (or macOS set the status
|
|
// to 'denied'/'restricted'), it resolves false with no prompt — leaving
|
|
// the user stuck. Fall through to the Privacy pane so they can toggle it.
|
|
const granted = await systemPreferences.askForMediaAccess(id)
|
|
if (granted) {
|
|
return { id, status: 'granted', openedSystemSettings: false }
|
|
}
|
|
const status = getMediaStatus(id)
|
|
if (status === 'denied' || status === 'restricted' || status === 'unknown') {
|
|
await openPrivacyPane(id)
|
|
return { id, status, openedSystemSettings: true }
|
|
}
|
|
return { id, status, openedSystemSettings: false }
|
|
}
|
|
|
|
if (id === 'accessibility') {
|
|
// Why: isTrustedAccessibilityClient(true) shows the prompt only the first
|
|
// time for a given bundle. Once the user has dismissed or denied, calling
|
|
// it again is a no-op. Fall through to the Privacy pane when not granted.
|
|
const trusted = systemPreferences.isTrustedAccessibilityClient(true)
|
|
if (trusted) {
|
|
return { id, status: 'granted', openedSystemSettings: false }
|
|
}
|
|
await openPrivacyPane(id)
|
|
return { id, status: getAccessibilityStatus(), openedSystemSettings: true }
|
|
}
|
|
|
|
if (id === 'automation') {
|
|
await triggerAppleEventsPrompt()
|
|
return { id, status: 'unknown', openedSystemSettings: false }
|
|
}
|
|
|
|
if (id === 'local-network') {
|
|
await triggerLocalNetworkPrompt()
|
|
return { id, status: 'unknown', openedSystemSettings: false }
|
|
}
|
|
|
|
await openPrivacyPane(id)
|
|
return { id, status: (await getPermissionState(id)).status, openedSystemSettings: true }
|
|
}
|
|
|
|
export function registerDeveloperPermissionHandlers(): void {
|
|
ipcMain.handle(
|
|
'developerPermissions:getStatus',
|
|
async (): Promise<DeveloperPermissionState[]> => {
|
|
return Promise.all(DEVELOPER_PERMISSION_IDS.map(getPermissionState))
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'developerPermissions:request',
|
|
async (
|
|
_event,
|
|
args: { id: DeveloperPermissionId }
|
|
): Promise<DeveloperPermissionRequestResult> => {
|
|
if (!DEVELOPER_PERMISSION_IDS.includes(args.id)) {
|
|
return { id: args.id, status: 'unsupported', openedSystemSettings: false }
|
|
}
|
|
return requestPermission(args.id)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'developerPermissions:openSettings',
|
|
async (_event, args: { id: DeveloperPermissionId }): Promise<void> => {
|
|
await openPrivacyPane(args.id)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle('developerPermissions:testLocalNetworkConnection', async (_event, args: unknown) =>
|
|
testLocalNetworkConnection(
|
|
args && typeof args === 'object' ? (args as { host?: unknown; port?: unknown }) : {}
|
|
)
|
|
)
|
|
}
|