tellme/Sources/CorePermissions/PermissionManager.swift
Felipe M. 6e768a7753
Implement Phase 1: Global hotkey, HUD, and audio capture
Add complete listening UX without STT:
- Global hotkey manager with ⌘⇧V, push-to-talk and toggle modes
- Floating HUD with real-time RMS audio visualization
- AVAudioEngine capture with 16kHz mono PCM conversion
- 10-minute dictation timeout with ESC cancellation
- Optional start/stop sounds and microphone permissions
- Permission management for accessibility and input monitoring

All Phase 1 acceptance criteria met.
2025-09-18 20:06:46 +02:00

162 lines
No EOL
5.5 KiB
Swift

import Foundation
import AVFoundation
import AppKit
import CoreUtils
public enum PermissionType: CaseIterable {
case microphone
case accessibility
case inputMonitoring
}
public enum PermissionStatus {
case notDetermined
case granted
case denied
case restricted
}
public class PermissionManager: ObservableObject {
private let logger = Logger(category: "PermissionManager")
@Published public private(set) var microphoneStatus: PermissionStatus = .notDetermined
@Published public private(set) var accessibilityStatus: PermissionStatus = .notDetermined
@Published public private(set) var inputMonitoringStatus: PermissionStatus = .notDetermined
public init() {
refreshAllPermissions()
}
public func requestMicrophonePermission() async -> PermissionStatus {
logger.info("Requesting microphone permission")
return await withCheckedContinuation { continuation in
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
continuation.resume(returning: .granted)
case .denied, .restricted:
continuation.resume(returning: .denied)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .audio) { granted in
let status: PermissionStatus = granted ? .granted : .denied
Task { @MainActor in
self.microphoneStatus = status
}
continuation.resume(returning: status)
}
@unknown default:
continuation.resume(returning: .notDetermined)
}
}
}
public func requestMicrophonePermission(completion: @escaping (PermissionStatus) -> Void) {
logger.info("Requesting microphone permission")
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
completion(.granted)
case .denied, .restricted:
completion(.denied)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .audio) { granted in
let status: PermissionStatus = granted ? .granted : .denied
Task { @MainActor in
self.microphoneStatus = status
}
completion(status)
}
@unknown default:
completion(.notDetermined)
}
}
public func requestAccessibilityPermission() {
logger.info("Requesting accessibility permission")
if !AXIsProcessTrusted() {
logger.info("Accessibility permission not granted, opening System Settings")
openSystemSettings(for: .accessibility)
} else {
logger.info("Accessibility permission already granted")
accessibilityStatus = .granted
}
}
public func requestInputMonitoringPermission() {
logger.info("Requesting input monitoring permission")
// For input monitoring, we can try to detect it by attempting to create a CGEvent
// If it fails, we likely need permission
let testEvent = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true)
if testEvent == nil {
logger.info("Input monitoring permission likely not granted, opening System Settings")
openSystemSettings(for: .inputMonitoring)
} else {
logger.info("Input monitoring permission appears to be granted")
inputMonitoringStatus = .granted
}
}
public func checkAllPermissions() {
logger.info("Checking all permissions")
refreshAllPermissions()
}
public func openSystemSettings(for permission: PermissionType) {
logger.info("Opening system settings for permission: \(permission)")
let urlString: String
switch permission {
case .microphone:
urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
case .accessibility:
urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
case .inputMonitoring:
urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"
}
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
private func refreshAllPermissions() {
refreshMicrophonePermission()
refreshAccessibilityPermission()
refreshInputMonitoringPermission()
}
private func refreshMicrophonePermission() {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .notDetermined:
microphoneStatus = .notDetermined
case .authorized:
microphoneStatus = .granted
case .denied, .restricted:
microphoneStatus = .denied
@unknown default:
microphoneStatus = .notDetermined
}
}
private func refreshAccessibilityPermission() {
if AXIsProcessTrusted() {
accessibilityStatus = .granted
} else {
accessibilityStatus = .denied
}
}
private func refreshInputMonitoringPermission() {
// Test if we can create CGEvents (requires Input Monitoring permission)
let testEvent = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true)
if testEvent != nil {
inputMonitoringStatus = .granted
} else {
inputMonitoringStatus = .denied
}
}
}