diff --git a/apps/macos/Sources/OpenClaw/PermissionManager.swift b/apps/macos/Sources/OpenClaw/PermissionManager.swift index 03bc329560ec..2e3ecc526205 100644 --- a/apps/macos/Sources/OpenClaw/PermissionManager.swift +++ b/apps/macos/Sources/OpenClaw/PermissionManager.swift @@ -36,6 +36,13 @@ enum PermissionManager { status == .authorized || status == .provisional } + static func shouldOpenSpeechRecognitionSettings( + status: SFSpeechRecognizerAuthorizationStatus, + interactive: Bool) -> Bool + { + interactive && (status == .denied || status == .restricted) + } + static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool { if requireAlways { return status == .authorizedAlways } switch status { @@ -96,7 +103,10 @@ enum PermissionManager { return granted && self.isNotificationAuthorized(status: updated.authorizationStatus) } if settings.authorizationStatus == .denied, interactive { - NotificationPermissionHelper.openSettings() + SystemSettingsURLSupport.openFirst([ + "x-apple.systempreferences:com.apple.Notifications-Settings.extension", + "x-apple.systempreferences:com.apple.preference.notifications", + ]) } return false } @@ -137,7 +147,7 @@ enum PermissionManager { return await AVCaptureDevice.requestAccess(for: .audio) case .denied, .restricted: if interactive { - MicrophonePermissionHelper.openSettings() + SystemSettingsURLSupport.openPrivacySettings(for: .microphone) } return false @unknown default: @@ -147,6 +157,9 @@ enum PermissionManager { private static func ensureSpeechRecognition(interactive: Bool) async -> Bool { let status = SFSpeechRecognizer.authorizationStatus() + if self.shouldOpenSpeechRecognitionSettings(status: status, interactive: interactive) { + SystemSettingsURLSupport.openPrivacySettings(for: .speechRecognition) + } if status == .notDetermined, interactive { await withUnsafeContinuation { (cont: UnsafeContinuation) in SFSpeechRecognizer.requestAuthorization { _ in @@ -167,7 +180,7 @@ enum PermissionManager { return await AVCaptureDevice.requestAccess(for: .video) case .denied, .restricted: if interactive { - CameraPermissionHelper.openSettings() + SystemSettingsURLSupport.openPrivacySettings(for: .camera) } return false @unknown default: @@ -178,7 +191,7 @@ enum PermissionManager { private static func ensureLocation(interactive: Bool) async -> Bool { guard CLLocationManager.locationServicesEnabled() else { if interactive { - await MainActor.run { LocationPermissionHelper.openSettings() } + await MainActor.run { SystemSettingsURLSupport.openPrivacySettings(for: .location) } } return false } @@ -192,7 +205,7 @@ enum PermissionManager { return self.isLocationAuthorized(status: updated, requireAlways: false) case .denied, .restricted: if interactive { - await MainActor.run { LocationPermissionHelper.openSettings() } + await MainActor.run { SystemSettingsURLSupport.openPrivacySettings(for: .location) } } return false @unknown default: @@ -271,42 +284,6 @@ enum PermissionManager { } } -enum NotificationPermissionHelper { - static func openSettings() { - SystemSettingsURLSupport.openFirst([ - "x-apple.systempreferences:com.apple.Notifications-Settings.extension", - "x-apple.systempreferences:com.apple.preference.notifications", - ]) - } -} - -enum MicrophonePermissionHelper { - static func openSettings() { - SystemSettingsURLSupport.openFirst([ - "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", - "x-apple.systempreferences:com.apple.preference.security", - ]) - } -} - -enum CameraPermissionHelper { - static func openSettings() { - SystemSettingsURLSupport.openFirst([ - "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", - "x-apple.systempreferences:com.apple.preference.security", - ]) - } -} - -enum LocationPermissionHelper { - static func openSettings() { - SystemSettingsURLSupport.openFirst([ - "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices", - "x-apple.systempreferences:com.apple.preference.security", - ]) - } -} - @MainActor final class LocationPermissionRequestCoordinator { private var continuations: [CheckedContinuation] = [] @@ -391,7 +368,7 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { return } guard !Task.isCancelled, let self, self.requests.hasPendingRequests else { return } - LocationPermissionHelper.openSettings() + SystemSettingsURLSupport.openPrivacySettings(for: .location) self.finish(status: self.manager.authorizationStatus) } } @@ -425,7 +402,7 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { let status = manager.authorizationStatus Task { @MainActor in if status == .denied || status == .restricted { - LocationPermissionHelper.openSettings() + SystemSettingsURLSupport.openPrivacySettings(for: .location) } self.finish(status: status) } diff --git a/apps/macos/Sources/OpenClaw/PermissionsSettings.swift b/apps/macos/Sources/OpenClaw/PermissionsSettings.swift index 760c0296b9fe..872f286f2f05 100644 --- a/apps/macos/Sources/OpenClaw/PermissionsSettings.swift +++ b/apps/macos/Sources/OpenClaw/PermissionsSettings.swift @@ -152,7 +152,7 @@ private struct LocationAccessSettings: View { private func requestLocationAuthorization(mode: OpenClawLocationMode) async -> Bool { guard mode != .off else { return true } guard CLLocationManager.locationServicesEnabled() else { - await MainActor.run { LocationPermissionHelper.openSettings() } + await MainActor.run { SystemSettingsURLSupport.openPrivacySettings(for: .location) } return false } diff --git a/apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift b/apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift index 114b3cdd4c57..7ea7127e462a 100644 --- a/apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift +++ b/apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift @@ -1,7 +1,27 @@ import AppKit import Foundation +import OpenClawIPC enum SystemSettingsURLSupport { + static func privacySettingsCandidates(for capability: Capability) -> [String] { + let pane: String? = switch capability { + case .microphone: "Microphone" + case .speechRecognition: "SpeechRecognition" + case .camera: "Camera" + case .location: "LocationServices" + default: nil + } + guard let pane else { return [] } + return [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_\(pane)", + "x-apple.systempreferences:com.apple.preference.security", + ] + } + + static func openPrivacySettings(for capability: Capability) { + self.openFirst(self.privacySettingsCandidates(for: capability)) + } + static func openFirst(_ candidates: [String]) { for candidate in candidates { if let url = URL(string: candidate), NSWorkspace.shared.open(url) { diff --git a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift index 93f55f55a618..0783b9ce8a6c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift @@ -1,5 +1,6 @@ import CoreLocation import OpenClawIPC +import Speech import Testing import UserNotifications @testable import OpenClaw @@ -7,6 +8,47 @@ import UserNotifications @Suite(.serialized) @MainActor struct PermissionManagerTests { + @Test(arguments: [ + (SFSpeechRecognizerAuthorizationStatus.notDetermined, false, false), + (.notDetermined, true, false), + (.denied, false, false), + (.denied, true, true), + (.restricted, false, false), + (.restricted, true, true), + (.authorized, false, false), + (.authorized, true, false), + ]) + func `speech recovery only opens Settings for an already denied interactive request`( + status: SFSpeechRecognizerAuthorizationStatus, + interactive: Bool, + shouldRecover: Bool) + { + #expect(PermissionManager.shouldOpenSpeechRecognitionSettings( + status: status, + interactive: interactive) == shouldRecover) + } + + @Test(arguments: [ + (Capability.microphone, "Microphone"), + (.speechRecognition, "SpeechRecognition"), + (.camera, "Camera"), + (.location, "LocationServices"), + ]) + func `denied privacy permissions route to their own System Settings pane`( + capability: Capability, + pane: String) + { + #expect(SystemSettingsURLSupport.privacySettingsCandidates(for: capability) == [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_\(pane)", + "x-apple.systempreferences:com.apple.preference.security", + ]) + } + + @Test(arguments: [Capability.appleScript, .notifications, .accessibility, .screenRecording]) + func `unrelated permissions do not inherit a privacy recovery pane`(capability: Capability) { + #expect(SystemSettingsURLSupport.privacySettingsCandidates(for: capability).isEmpty) + } + @Test func `notification authorization accepts provisional delivery`() throws { #expect(PermissionManager.isNotificationAuthorized(status: .authorized)) #expect(PermissionManager.isNotificationAuthorized(status: .provisional))