fix(macos): recover denied speech recognition permissions (#129950)

This commit is contained in:
Peter Steinberger
2026-08-26 01:02:54 -07:00
committed by GitHub
parent 94a170089d
commit f26c053605
4 changed files with 83 additions and 44 deletions
@@ -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<Void, Never>) 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<CLAuthorizationStatus, Never>] = []
@@ -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)
}
@@ -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
}
@@ -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) {
@@ -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))