# macOS Permission Manager

**Source:** `apps/macos/Sources/SOPHIAClaw/Core/PermissionManager.swift` (387 lines)  
**Category:** UI/UX Components - System Integration  
**Purpose:** Centralized macOS permission requests with interactive and non-interactive modes

## Overview

The Permission Manager provides a unified interface for requesting, checking, and handling macOS system permissions. It abstracts the complexity of multiple permission APIs (AVFoundation, CoreLocation, Speech, UserNotifications, Accessibility) into a single capability-based system with consistent error handling and user guidance.

```
┌─────────────────────────────────────────────────────────────┐
│                    UI (SettingsView)                        │
│  [ ] Microphone Access                                      │
│  [ ] Screen Recording                                       │
│  [ ] Accessibility                                          │
│  [Check Permissions]                                        │
└─────────────────────┬───────────────────────────────────────┘
                      │ ensure([...], interactive: true)
                      v
┌─────────────────────────────────────────────────────────────┐
│                PermissionManager                            │
│                                                             │
│  ┌───────────────────────────────────────────────────────┐ │
│  │  Capability Router (switch case)                      │ │
│  │  → .notifications  → ensureNotifications()            │ │
│  │  → .accessibility  → ensureAccessibility()            │ │
│  │  → .screenRecording → ensureScreenRecording()         │ │
│  │  → .microphone     → ensureMicrophone()               │ │
│  │  → .speechRecognition → ensureSpeechRecognition()     │ │
│  │  → .camera         → ensureCamera()                   │ │
│  │  → .location       → ensureLocation()                 │ │
│  │  → .appleScript    → ensureAppleScript()              │ │
│  └───────────────────────────────────────────────────────┘ │
└─────────────────────┬───────────────────────────────────────┘
                      │ queries system APIs
                      v
┌─────────────────────────────────────────────────────────────┐
│              macOS System Frameworks                        │
│  - AVFoundation (microphone, camera)                        │
│  - Speech (SFSpeechRecognizer)                              │
│  - UserNotifications (UNUserNotificationCenter)             │
│  - ApplicationServices (Accessibility)                      │
│  - CoreGraphics (Screen Recording)                          │
│  - CoreLocation (Location Services)                         │
│  - Apple Event Manager (AppleScript)                        │
└─────────────────────────────────────────────────────────────┘
```

## Capability System

### Enum-Based Capabilities

```swift
enum Capability: CaseIterable, Hashable, Codable {
    case notifications
    case appleScript
    case accessibility
    case screenRecording
    case microphone
    case speechRecognition
    case camera
    case location
}

// All capabilities for status display
let allCapabilities = Capability.allCases
```

### Permission Status States

```swift
enum PermissionStatus: String, Codable, Sendable {
    case authorized       // Permission granted
    case denied          // Permission explicitly denied
    case notDetermined   // Not yet requested
}
```

## Permission Request Algorithms

### 1. Notifications Permission

**Framework:** UserNotifications

```swift
private static func ensureNotifications(interactive: Bool) async -> Bool {
    // Check current status
    let status = await NotificationManager.shared.authorizationStatus

    switch status {
    case .authorized, .provisional, .ephemeral:
        return true  // Already granted (provisional/ephemeral count)

    case .notDetermined:
        // Only request if interactive mode
        guard interactive else { return false }
        return await NotificationManager.shared.requestAuthorization()

    case .denied:
        // Can't request again - open settings
        if interactive {
            NotificationPermissionHelper.openSettings()
        }
        return false

    @unknown default:
        return false
    }
}

// Notification authorization request
extension NotificationManager {
    func requestAuthorization() async -> Bool {
        await withCheckedContinuation { continuation in
            UNUserNotificationCenter.current().requestAuthorization(
                options: [.alert, .sound, .badge]
            ) { granted, error in
                continuation.resume(returning: granted)
            }
        }
    }
}
```

**Status Mapping:**
| UNAuthorizationStatus | PermissionStatus |
|----------------------|------------------|
| `.authorized` | `.authorized` |
| `.provisional` | `.authorized` |
| `.ephemeral` | `.authorized` |
| `.denied` | `.denied` |
| `.notDetermined` | `.notDetermined` |

### 2. Accessibility Permission

**Framework:** ApplicationServices (AXIsProcessTrusted)

```swift
private static func ensureAccessibility(interactive: Bool) async -> Bool {
    // Check current trust status
    let trusted = await MainActor.run { AXIsProcessTrusted() }

    if interactive && !trusted {
        // Show system prompt with explanation
        await MainActor.run {
            let options: NSDictionary = [
                "AXTrustedCheckOptionPrompt": true
            ]
            _ = AXIsProcessTrustedWithOptions(options)
        }
    }

    // Re-check after prompt
    return await MainActor.run { AXIsProcessTrusted() }
}
```

**Key Properties:**

- **All-or-nothing**: Either fully trusted or not (no granular control)
- **Persistent**: User grants once, persists across launches
- **System prompt**: Only shown if `interactive=true`

**Settings URL:** `x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility`

### 3. Screen Recording Permission

**Framework:** CoreGraphics (CGPreflightScreenCaptureAccess)

```swift
private static func ensureScreenRecording(interactive: Bool) async -> Bool {
    // macOS 10.15+ API
    let granted = ScreenRecordingProbe.isAuthorized()

    if interactive && !granted {
        // Trigger system prompt
        await ScreenRecordingProbe.requestAuthorization()
    }

    return ScreenRecordingProbe.isAuthorized()
}

enum ScreenRecordingProbe {
    static func isAuthorized() -> Bool {
        if #available(macOS 10.15, *) {
            return CGPreflightScreenCaptureAccess()
        }
        return true  // Pre-10.15: always allowed
    }

    static func requestAuthorization() {
        if #available(macOS 10.15, *) {
            _ = CGRequestScreenCaptureAccess()
        }
    }
}
```

**Behavior:**

- `CGPreflightScreenCaptureAccess()`: Non-blocking check
- `CGRequestScreenCaptureAccess()`: Shows system dialog (async)
- **Re-entrancy**: Returns immediately if already granted/denied

### 4. Microphone Permission

**Framework:** AVFoundation (AVCaptureDevice)

```swift
private static func ensureMicrophone(interactive: Bool) async -> Bool {
    let status = AVCaptureDevice.authorizationStatus(for: .audio)

    switch status {
    case .authorized:
        return true

    case .notDetermined:
        // Only request in interactive mode
        guard interactive else { return false }
        return await AVCaptureDevice.requestAccess(for: .audio)

    case .denied, .restricted:
        // Can't request again - open settings
        if interactive {
            MicrophonePermissionHelper.openSettings()
        }
        return false

    @unknown default:
        return false
    }
}

// AVCaptureDevice async wrapper
extension AVCaptureDevice {
    static func requestAccess(for mediaType: AVMediaType) async -> Bool {
        await withCheckedContinuation { continuation in
            requestAccess(for: mediaType) { granted in
                continuation.resume(returning: granted)
            }
        }
    }
}
```

**Settings URL:** `x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone`

### 5. Speech Recognition Permission

**Framework:** Speech (SFSpeechRecognizer)

```swift
private static func ensureSpeechRecognition(interactive: Bool) async -> Bool {
    let status = SFSpeechRecognizer.authorizationStatus()

    // Request if not determined
    if status == .notDetermined && interactive {
        await withCheckedContinuation { continuation in
            SFSpeechRecognizer.requestAuthorization { status in
                DispatchQueue.main.async {
                    continuation.resume()
                }
            }
        }
    }

    return SFSpeechRecognizer.authorizationStatus() == .authorized
}
```

**Authorization Status Values:**

- `.authorized`: Can use speech recognition
- `.denied`: User denied or restricted
- `.notDetermined`: Not yet requested
- `.restricted`: Parental controls
- `@unknown default`: Future-proof handling

### 6. Camera Permission

**Framework:** AVFoundation (AVCaptureDevice for .video)

```swift
private static func ensureCamera(interactive: Bool) async -> Bool {
    let status = AVCaptureDevice.authorizationStatus(for: .video)

    switch status {
    case .authorized:
        return true

    case .notDetermined:
        guard interactive else { return false }
        return await AVCaptureDevice.requestAccess(for: .video)

    case .denied, .restricted:
        if interactive {
            CameraPermissionHelper.openSettings()
        }
        return false

    @unknown default:
        return false
    }
}
```

**Settings URL:** `x-apple.systempreferences:com.apple.preference.security?Privacy_Camera`

### 7. Location Permission

**Framework:** CoreLocation (CLLocationManager)

```swift
private static func ensureLocation(interactive: Bool) async -> Bool {
    // Check if location services enabled globally
    guard CLLocationManager.locationServicesEnabled() else {
        if interactive {
            await MainActor.run { LocationPermissionHelper.openSettings() }
        }
        return false
    }

    // Check app-specific authorization
    let status = CLLocationManager().authorizationStatus

    switch status {
    case .authorizedAlways, .authorizedWhenInUse, .authorized:
        return true

    case .notDetermined:
        guard interactive else { return false }
        let updated = await LocationPermissionRequester.shared.request(always: false)
        return isLocationAuthorized(status: updated, requireAlways: false)

    case .denied, .restricted:
        if interactive {
            await MainActor.run { LocationPermissionHelper.openSettings() }
        }
        return false

    @unknown default:
        return false
    }
}
```

**Async Delegate Pattern:**

```swift
@MainActor
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
    static let shared = LocationPermissionRequester()
    private let manager = CLLocationManager()
    private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?

    func request(always: Bool) async -> CLAuthorizationStatus {
        let currentStatus = manager.authorizationStatus
        if currentStatus != .notDetermined {
            return currentStatus  // Already decided
        }

        //Await async via continuation
        return await withCheckedContinuation { cont in
            self.continuation = cont

            if always {
                manager.requestAlwaysAuthorization()
            } else {
                manager.requestWhenInUseAuthorization()
            }
        }
    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        // Resume continuation with new status
        continuation?.resume(returning: manager.authorizationStatus)
        continuation = nil
    }
}
```

**Authorization Levels:**

- `.authorizedWhenInUse`: Only while app in foreground
- `.authorizedAlways`: Background location (requires justification)
- `.denied`: User rejected

### 8. AppleScript Permission

**Framework:** Apple Event Manager (AEDeterminePermissionToAutomateTarget)

```swift
enum AppleScriptPermission {
    static func checkStatus(interactive: Bool) -> PermissionStatus {
        // Check permission to automate Finder (baseline)
        let bundleID = "com.apple.finder"
        var targetAddr = AEAddressDesc()

        // Create AE address descriptor
        let bundleIDData = bundleID.data(using: .utf8)!
        let status = bundleIDData.withUnsafeBytes { ptr in
            AECreateDesc(
                OSType(typeApplicationBundleID),
                ptr.baseAddress,
                bundleIDData.count,
                &targetAddr
            )
        }

        guard status == noErr else { return .notDetermined }
        defer { AEDisposeDesc(&targetAddr) }

        // Determine automation permission
        let permissionStatus = AEDeterminePermissionToAutomateTarget(
            &targetAddr,
            OSType(typeWildCard),  // Any event class
            OSType(typeWildCard),  // Any event ID
            interactive            // Show prompt if true
        )

        switch permissionStatus {
        case noErr:
            return .authorized
        case OSStatus(-1743):  // errAEEventNotAllowed
            return .denied
        case OSStatus(-600):   // procNotFound
            return .notDetermined
        default:
            // Fallback: try simple script
            return checkViaScript()
        }
    }

    private static func checkViaScript() -> PermissionStatus {
        let script = "tell application \"Finder\" to get name"
        var error: NSDictionary?

        if let _ = NSAppleScript(source: script)?.executeAndReturnError(&error) {
            return .authorized
        }

        if let error = error,
           let code = error[NSAppleScript.errorNumber] as? Int,
           code == -1743 {
            return .denied
        }

        return .notDetermined
    }
}
```

**Settings URL:** `x-apple.systempreferences:com.apple.preference.security?Privacy_Automation`

## Batch Permission Operations

### Parallel Permission Check

```swift
static func status(_ caps: [Capability] = .allCases) async -> [Capability: PermissionStatus] {
    // Check all requested permissions in parallel
    await withTaskGroup(of: (Capability, PermissionStatus).self) { group in
        for cap in caps {
            group.addTask {
                let status = await checkSingle(cap)
                return (cap, status)
            }
        }

        var results: [Capability: PermissionStatus] = [:]
        for await (cap, status) in group {
            results[cap] = status
        }
        return results
    }
}
```

### Batch Request

```swift
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
    var results: [Capability: Bool] = [:]

    // Sequential requests (better UX than parallel prompts)
    for cap in caps {
        results[cap] = await ensureCapability(cap, interactive: interactive)
    }

    return results
}
```

**Why Sequential?**

- Avoids overwhelming user with multiple system dialogs
- User can see which permissions succeeded/failed
- Allows recovery between requests

## Settings Integration

### Deep Linking to System Preferences

```swift
enum NotificationPermissionHelper {
    static func openSettings() {
        // Multiple URL schemes (fallback chain)
        let candidates = [
            "x-apple.systempreferences:com.apple.Notifications-Settings.extension",
            "x-apple.systempreferences:com.apple.preference.notifications"
        ]

        for candidate in candidates {
            if let url = URL(string: candidate),
               NSWorkspace.shared.open(url) {
                return
            }
        }
    }
}

enum MicrophonePermissionHelper {
    static func openSettings() {
        let url = URL(
            string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
        )!
        NSWorkspace.shared.open(url)
    }
}
```

**System Preference URLs:**

| Permission       | URL Scheme                                                                         |
| ---------------- | ---------------------------------------------------------------------------------- |
| Notifications    | `x-apple.systempreferences:com.apple.Notifications-Settings.extension`             |
| Microphone       | `x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone`       |
| Camera           | `x-apple.systempreferences:com.apple.preference.security?Privacy_Camera`           |
| Accessibility    | `x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility`    |
| Screen Recording | `x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenSharing`    |
| Automation       | `x-apple.systempreferences:com.apple.preference.security?Privacy_Automation`       |
| Location         | `x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices` |

## Interactive vs Non-Interactive Mode

### Mode Behavior Matrix

| Mode                 | Already Granted | Not Determined            | Denied           |
| -------------------- | --------------- | ------------------------- | ---------------- |
| `interactive: true`  | ✅ Return true  | 🔄 Request, return result | ❌ Open settings |
| `interactive: false` | ✅ Return true  | ❌ Return false           | ❌ Return false  |

### Use Cases

**Interactive Mode:**

```swift
// User clicks "Request Permissions" in settings UI
let results = await PermissionManager.ensure(
    [.microphone, .speechRecognition],
    interactive: true
)
```

**Non-Interactive Mode:**

```swift
// App startup check (silent)
let status = await PermissionManager.status([.microphone])
if status[.microphone] != .authorized {
    // Show disabled UI, don't prompt user
}
```

## Error Handling Philosophy

### Silent Failure Policy

```swift
// Don't throw errors for permission denials
// Return false and let UI decide how to handle
func ensureMicrophone(interactive: Bool) async -> Bool {
    // ... check logic ...
    return false  // Not an error, just a state
}
```

### Unknown Future-Proofing

```swift
switch status {
case .authorized: return true
case .denied: return false
// ... explicit cases ...
@unknown default:
    // New status added in future macOS version
    logger.warning("Unknown authorization status: \(status)")
    return false  // Conservative default
}
```

## Threading Model

### Main Actor Confinement

```swift
// All UI-related permission prompts must run on main actor
@MainActor
func ensureAccessibility(interactive: Bool) async -> Bool {
    await MainActor.run {
        AXIsProcessTrustedWithOptions(options)
    }
}
```

### Continuation Safety

```swift
func requestAuthorization() async -> Bool {
    await withCheckedContinuation { continuation in
        UNUserNotificationCenter.current().requestAuthorization { granted, error in
            // Ensure callback on main actor
            DispatchQueue.main.async {
                continuation.resume(returning: granted)
            }
        }
    }
}
```

## Privacy Info.plist Keys

**Required Entitlements:**

```xml
<!-- Microphone -->
<key>NSMicrophoneUsageDescription</key>
<string>SophiaClaw needs microphone access for voice commands</string>

<!-- Camera -->
<key>NSCameraUsageDescription</key>
<string>SophiaClaw needs camera access for video analysis</string>

<!-- Location -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>SophiaClaw uses location for context-aware responses</string>

<!-- Speech Recognition -->
<key>NSSpeechRecognitionUsageDescription</key>
<string>SophiaClaw uses speech recognition for voice wake</string>
```

## Testing Strategy

### Mock Permission Status

```swift
// Test helper for simulating different permission states
struct MockPermissionManager {
    var microphoneStatus: AVAuthorizationStatus = .notDetermined

    func ensureMicrophone(interactive: Bool) async -> Bool {
        // Simulated logic for testing
        return microphoneStatus == .authorized
    }
}
```

### Integration Tests

```swift
@MainActor
func testPermissionRequestFlow() async {
    // Start with not determined
    let status = await PermissionManager.status([.microphone])
    XCTAssertEqual(status[.microphone], .notDetermined)

    // Request interactively (user interaction required in real test)
    let results = await PermissionManager.ensure([.microphone], interactive: true)

    // Check result (depends on user action in manual test)
    XCTAssertTrue(results[.microphone] == true || results[.microphone] == false)
}
```

## UI Integration

### Permission Row Component

```swift
struct PermissionRow: View {
    let capability: Capability
    let status: PermissionStatus

    var body: some View {
        HStack {
            Image(systemName: status.icon)
                .foregroundColor(status.color)

            Text(capability.displayName)

            Spacer()

            switch status {
            case .authorized:
                Text("Granted").foregroundColor(.green)
            case .denied:
                Button("Open Settings") {
                    openSystemSettings(for: capability)
                }
            case .notDetermined:
                Button("Request") {
                    await requestPermission(capability)
                }
            }
        }
    }
}
```

## Related Documentation

- [Voice Wake Service](/docs/platforms/macos/voice-wake.md)
- [System Preferences Integration](/docs/platforms/macos/system-integration.md)
- [Privacy Manifest](/docs/platforms/macos/privacy-manifest.md)
