Files
clawdbot/apps/macos/Sources/Clawdis/PermissionManager.swift
Tu Nombre Real b978cc4e91 feat(macos): add Swift 6 strict concurrency compatibility
Prepares the macOS app for Swift 6 strict concurrency mode by:

1. Adding Sendable conformance to WizardNextResult, WizardStartResult,
   and WizardStatusResult in GatewayModels.swift

2. Adding AnyCodable bridging helpers in OnboardingWizard.swift to
   handle type conflicts between ClawdisProtocol and local module

3. Making CLLocationManagerDelegate methods nonisolated in:
   - MacNodeLocationService.swift
   - PermissionManager.swift (LocationPermissionRequester)

   Using Task { @MainActor in } pattern to safely access MainActor
   state from nonisolated protocol requirements.

These changes are forward-compatible and don't affect behavior on
current Swift versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 06:09:52 +01:00

435 lines
14 KiB
Swift

import AppKit
import ApplicationServices
import AVFoundation
import ClawdisIPC
import CoreGraphics
import CoreLocation
import Foundation
import Observation
import Speech
import UserNotifications
enum PermissionManager {
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<Void, Never>) 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 {
let status = CLLocationManager.authorizationStatus()
switch status {
case .authorizedAlways, .authorizedWhenInUse:
return true
case .notDetermined:
guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false)
return updated == .authorizedAlways || updated == .authorizedWhenInUse
case .denied, .restricted:
if interactive {
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] = status == .authorizedAlways || status == .authorizedWhenInUse
}
}
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<CLAuthorizationStatus, Never>?
override init() {
super.init()
self.manager.delegate = self
}
func request(always: Bool) async -> CLAuthorizationStatus {
if always {
self.manager.requestAlwaysAuthorization()
} else {
self.manager.requestWhenInUseAuthorization()
}
return await withCheckedContinuation { cont in
self.continuation = cont
}
}
// nonisolated for Swift 6 strict concurrency compatibility
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: status)
}
}
}
enum AppleScriptPermission {
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")
/// Sends a benign AppleScript to Terminal to verify Automation permission.
@MainActor
static func isAuthorized() -> Bool {
let script = """
tell application "Terminal"
return "clawdis-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()
}
}
}