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.
162 lines
No EOL
5.5 KiB
Swift
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
|
|
}
|
|
}
|
|
} |