import AppKit import ApplicationServices import AVFoundation import ClawdbotIPC import CoreGraphics import CoreLocation import Foundation import Observation import Speech import UserNotifications enum PermissionManager { static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool { if requireAlways { return status == .authorizedAlways } switch status { case .authorizedAlways, .authorizedWhenInUse: return true case .authorized: // deprecated, but still shows up on some macOS versions return true default: return false } } static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { var results: [Capability: Bool] = [:] for cap in caps { results[cap] = await self.ensureCapability(cap, interactive: interactive) } return results } private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool { switch cap { case .notifications: await self.ensureNotifications(interactive: interactive) case .appleScript: await self.ensureAppleScript(interactive: interactive) case .accessibility: await self.ensureAccessibility(interactive: interactive) case .screenRecording: await self.ensureScreenRecording(interactive: interactive) case .microphone: await self.ensureMicrophone(interactive: interactive) case .speechRecognition: await self.ensureSpeechRecognition(interactive: interactive) case .camera: await self.ensureCamera(interactive: interactive) case .location: await self.ensureLocation(interactive: interactive) } } private static func ensureNotifications(interactive: Bool) async -> Bool { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() switch settings.authorizationStatus { case .authorized, .provisional, .ephemeral: return true case .notDetermined: guard interactive else { return false } let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false let updated = await center.notificationSettings() return granted && (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional) case .denied: if interactive { NotificationPermissionHelper.openSettings() } return false @unknown default: return false } } private static func ensureAppleScript(interactive: Bool) async -> Bool { let granted = await MainActor.run { AppleScriptPermission.isAuthorized() } if interactive, !granted { await AppleScriptPermission.requestAuthorization() } return await MainActor.run { AppleScriptPermission.isAuthorized() } } private static func ensureAccessibility(interactive: Bool) async -> Bool { let trusted = await MainActor.run { AXIsProcessTrusted() } if interactive, !trusted { await MainActor.run { let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] _ = AXIsProcessTrustedWithOptions(opts) } } return await MainActor.run { AXIsProcessTrusted() } } private static func ensureScreenRecording(interactive: Bool) async -> Bool { let granted = ScreenRecordingProbe.isAuthorized() if interactive, !granted { await ScreenRecordingProbe.requestAuthorization() } return ScreenRecordingProbe.isAuthorized() } private static func ensureMicrophone(interactive: Bool) async -> Bool { let status = AVCaptureDevice.authorizationStatus(for: .audio) switch status { case .authorized: return true case .notDetermined: guard interactive else { return false } return await AVCaptureDevice.requestAccess(for: .audio) case .denied, .restricted: if interactive { MicrophonePermissionHelper.openSettings() } return false @unknown default: return false } } private static func ensureSpeechRecognition(interactive: Bool) async -> Bool { let status = SFSpeechRecognizer.authorizationStatus() if status == .notDetermined, interactive { await withUnsafeContinuation { (cont: UnsafeContinuation) in SFSpeechRecognizer.requestAuthorization { _ in DispatchQueue.main.async { cont.resume() } } } } return SFSpeechRecognizer.authorizationStatus() == .authorized } 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 } } private static func ensureLocation(interactive: Bool) async -> Bool { guard CLLocationManager.locationServicesEnabled() else { if interactive { await MainActor.run { LocationPermissionHelper.openSettings() } } return false } 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 self.isLocationAuthorized(status: updated, requireAlways: false) case .denied, .restricted: if interactive { await MainActor.run { LocationPermissionHelper.openSettings() } } return false @unknown default: return false } } static func voiceWakePermissionsGranted() -> Bool { let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized let speech = SFSpeechRecognizer.authorizationStatus() == .authorized return mic && speech } static func ensureVoiceWakePermissions(interactive: Bool) async -> Bool { let results = await self.ensure([.microphone, .speechRecognition], interactive: interactive) return results[.microphone] == true && results[.speechRecognition] == true } static func status(_ caps: [Capability] = Capability.allCases) async -> [Capability: Bool] { var results: [Capability: Bool] = [:] for cap in caps { switch cap { case .notifications: let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() results[cap] = settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional case .appleScript: results[cap] = await MainActor.run { AppleScriptPermission.isAuthorized() } case .accessibility: results[cap] = await MainActor.run { AXIsProcessTrusted() } case .screenRecording: if #available(macOS 10.15, *) { results[cap] = CGPreflightScreenCaptureAccess() } else { results[cap] = true } case .microphone: results[cap] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized case .speechRecognition: results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized case .camera: results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized case .location: let status = CLLocationManager().authorizationStatus results[cap] = CLLocationManager.locationServicesEnabled() && self.isLocationAuthorized(status: status, requireAlways: false) } } return results } } enum NotificationPermissionHelper { static func openSettings() { 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 candidates = [ "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", "x-apple.systempreferences:com.apple.preference.security", ] for candidate in candidates { if let url = URL(string: candidate), NSWorkspace.shared.open(url) { return } } } } enum CameraPermissionHelper { static func openSettings() { let candidates = [ "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", "x-apple.systempreferences:com.apple.preference.security", ] for candidate in candidates { if let url = URL(string: candidate), NSWorkspace.shared.open(url) { return } } } } enum LocationPermissionHelper { static func openSettings() { let candidates = [ "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices", "x-apple.systempreferences:com.apple.preference.security", ] for candidate in candidates { if let url = URL(string: candidate), NSWorkspace.shared.open(url) { return } } } } @MainActor final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { static let shared = LocationPermissionRequester() private let manager = CLLocationManager() private var continuation: CheckedContinuation? private var timeoutTask: Task? override init() { super.init() self.manager.delegate = self } func request(always: Bool) async -> CLAuthorizationStatus { let current = self.manager.authorizationStatus if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) { return current } return await withCheckedContinuation { cont in self.continuation = cont self.timeoutTask?.cancel() self.timeoutTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 3_000_000_000) await MainActor.run { [weak self] in guard let self else { return } guard self.continuation != nil else { return } LocationPermissionHelper.openSettings() self.finish(status: self.manager.authorizationStatus) } } if always { self.manager.requestAlwaysAuthorization() } else { self.manager.requestWhenInUseAuthorization() } // On macOS, requesting an actual fix makes the prompt more reliable. self.manager.requestLocation() } } private func finish(status: CLAuthorizationStatus) { self.timeoutTask?.cancel() self.timeoutTask = nil guard let cont = self.continuation else { return } self.continuation = nil cont.resume(returning: status) } // nonisolated for Swift 6 strict concurrency compatibility nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus Task { @MainActor in self.finish(status: status) } } // Legacy callback (still used on some macOS versions / configurations). nonisolated func locationManager( _ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { Task { @MainActor in self.finish(status: status) } } nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { let status = manager.authorizationStatus Task { @MainActor in if status == .denied || status == .restricted { LocationPermissionHelper.openSettings() } self.finish(status: status) } } nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { let status = manager.authorizationStatus Task { @MainActor in self.finish(status: status) } } } enum AppleScriptPermission { private static let logger = Logger(subsystem: "com.clawdbot", category: "AppleScriptPermission") /// Sends a benign AppleScript to Terminal to verify Automation permission. @MainActor static func isAuthorized() -> Bool { let script = """ tell application "Terminal" return "clawdbot-ok" end tell """ var error: NSDictionary? let appleScript = NSAppleScript(source: script) let result = appleScript?.executeAndReturnError(&error) if let error, let code = error["NSAppleScriptErrorNumber"] as? Int { if code == -1743 { // errAEEventWouldRequireUserConsent Self.logger.debug("AppleScript permission denied (-1743)") return false } Self.logger.debug("AppleScript check failed with code \(code)") } return result != nil } /// Triggers the TCC prompt and opens System Settings → Privacy & Security → Automation. @MainActor static func requestAuthorization() async { _ = self.isAuthorized() // first attempt triggers the dialog if not granted // Open the Automation pane to help the user if the prompt was dismissed. let urlStrings = [ "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", "x-apple.systempreferences:com.apple.preference.security", ] for candidate in urlStrings { if let url = URL(string: candidate), NSWorkspace.shared.open(url) { break } } } } @MainActor @Observable final class PermissionMonitor { static let shared = PermissionMonitor() private(set) var status: [Capability: Bool] = [:] private var monitorTimer: Timer? private var isChecking = false private var registrations = 0 private var lastCheck: Date? private let minimumCheckInterval: TimeInterval = 0.5 func register() { self.registrations += 1 if self.registrations == 1 { self.startMonitoring() } } func unregister() { guard self.registrations > 0 else { return } self.registrations -= 1 if self.registrations == 0 { self.stopMonitoring() } } func refreshNow() async { await self.checkStatus(force: true) } private func startMonitoring() { Task { await self.checkStatus(force: true) } if ProcessInfo.processInfo.isRunningTests { return } self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let self else { return } Task { @MainActor in await self.checkStatus(force: false) } } } private func stopMonitoring() { self.monitorTimer?.invalidate() self.monitorTimer = nil self.lastCheck = nil } private func checkStatus(force: Bool) async { if self.isChecking { return } let now = Date() if !force, let lastCheck, now.timeIntervalSince(lastCheck) < self.minimumCheckInterval { return } self.isChecking = true let latest = await PermissionManager.status() if latest != self.status { self.status = latest } self.lastCheck = Date() self.isChecking = false } } enum ScreenRecordingProbe { static func isAuthorized() -> Bool { if #available(macOS 10.15, *) { return CGPreflightScreenCaptureAccess() } return true } @MainActor static func requestAuthorization() async { if #available(macOS 10.15, *) { _ = CGRequestScreenCaptureAccess() } } }