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> = { 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 { 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 { return new Promise((resolve) => { let child: ReturnType | 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 { return new Promise((resolve) => { const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) let settled = false let timeout: ReturnType | 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 { 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 { 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 => { return Promise.all(DEVELOPER_PERMISSION_IDS.map(getPermissionState)) } ) ipcMain.handle( 'developerPermissions:request', async ( _event, args: { id: DeveloperPermissionId } ): Promise => { 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 => { await openPrivacyPane(args.id) } ) ipcMain.handle('developerPermissions:testLocalNetworkConnection', async (_event, args: unknown) => testLocalNetworkConnection( args && typeof args === 'object' ? (args as { host?: unknown; port?: unknown }) : {} ) ) }