diff --git a/Sources/VocaMac/Models/AppState.swift b/Sources/VocaMac/Models/AppState.swift index 97c1325..70ce1f9 100644 --- a/Sources/VocaMac/Models/AppState.swift +++ b/Sources/VocaMac/Models/AppState.swift @@ -191,12 +191,8 @@ final class AppState: ObservableObject { // MARK: - Permission Handling func checkPermissions() { - // Check microphone permission - audioEngine.checkPermission { [weak self] granted in - Task { @MainActor in - self?.micPermission = granted ? .granted : .denied - } - } + // Check microphone permission (tri-state: notDetermined, granted, denied) + micPermission = audioEngine.checkPermissionStatus() // Check accessibility permission let accessibilityGranted = HotKeyManager.checkAccessibilityPermission(prompt: false) @@ -210,19 +206,22 @@ final class AppState: ObservableObject { } /// Check Input Monitoring permission. - /// If the HotKeyManager has an active event tap, we check if macOS has disabled it - /// (which happens when the user revokes Input Monitoring permission). - /// Otherwise, we try to create a temporary tap to test. + /// Uses multiple strategies since no single approach is 100% reliable: + /// 1. If HotKeyManager created a tap, check if macOS has disabled it (revocation) + /// 2. If HotKeyManager failed to create a tap, permission is likely denied + /// 3. Try creating a fresh .cghidEventTap (same type HotKeyManager uses) private func checkInputMonitoringPermission() -> Bool { - // If HotKeyManager has an active tap, check if it's still enabled - // macOS disables existing taps when Input Monitoring is revoked + // Strategy 1: If HotKeyManager has an active tap, check if macOS disabled it. + // macOS disables existing taps when Input Monitoring is revoked. if hotKeyManager.isListening, let tap = hotKeyManager.activeEventTap { return CGEvent.tapIsEnabled(tap: tap) } - // No active tap — try creating a temporary one to test permission + // Strategy 2: Try creating a fresh .cghidEventTap — the same type + // HotKeyManager uses. This is more accurate than .cgSessionEventTap + // which may inherit Terminal's permissions when launched from CLI. let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, + tap: .cghidEventTap, place: .headInsertEventTap, options: .listenOnly, eventsOfInterest: CGEventMask(1 << CGEventType.keyDown.rawValue), @@ -237,6 +236,14 @@ final class AppState: ObservableObject { } func requestMicrophonePermission() { + if micPermission == .denied { + // Already denied — re-requesting won't show the prompt again. + // Open System Settings so the user can manually enable it. + openMicrophoneSettings() + return + } + + // First time or notDetermined — trigger the system permission prompt audioEngine.requestPermission { [weak self] granted in Task { @MainActor in self?.micPermission = granted ? .granted : .denied @@ -244,6 +251,17 @@ final class AppState: ObservableObject { } } + /// Open the Microphone privacy pane in System Settings + func openMicrophoneSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") { + NSWorkspace.shared.open(url) + } + // Re-check after user has time to toggle + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.checkPermissions() + } + } + func requestAccessibilityPermission() { let _ = HotKeyManager.checkAccessibilityPermission(prompt: true) // User must manually enable in System Settings; re-check after delay @@ -255,8 +273,9 @@ final class AppState: ObservableObject { func requestInputMonitoringPermission() { // Attempting to create an event tap triggers macOS to auto-add // the app to the Input Monitoring list in System Settings. + // Use .cghidEventTap (same as HotKeyManager) for consistent behavior. let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, + tap: .cghidEventTap, place: .headInsertEventTap, options: .listenOnly, eventsOfInterest: CGEventMask(1 << CGEventType.keyDown.rawValue), @@ -469,8 +488,14 @@ final class AppState: ObservableObject { // 2. Check/request permissions checkPermissions() - NSLog("[AppState] Mic permission: %@ | Accessibility: %@", - micPermission.rawValue, accessibilityPermission.rawValue) + NSLog("[AppState] Mic permission: %@ | Accessibility: %@ | Input Monitoring: %@", + micPermission.rawValue, accessibilityPermission.rawValue, inputMonitoringPermission.rawValue) + + // Auto-prompt for microphone permission on first launch + if micPermission == .notDetermined { + NSLog("[AppState] Mic permission not determined — requesting...") + requestMicrophonePermission() + } // 3. Load model — let WhisperKit auto-select and download the best model NSLog("[AppState] Loading WhisperKit model (auto-select)...") diff --git a/Sources/VocaMac/Services/AudioEngine.swift b/Sources/VocaMac/Services/AudioEngine.swift index 7140190..8cd6d55 100644 --- a/Sources/VocaMac/Services/AudioEngine.swift +++ b/Sources/VocaMac/Services/AudioEngine.swift @@ -48,17 +48,17 @@ final class AudioEngine { // MARK: - Permission Handling - /// Check current microphone permission status - func checkPermission(completion: @escaping (Bool) -> Void) { + /// Check current microphone permission status (tri-state) + func checkPermissionStatus() -> PermissionStatus { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: - completion(true) + return .granted case .notDetermined: - completion(false) + return .notDetermined case .denied, .restricted: - completion(false) + return .denied @unknown default: - completion(false) + return .denied } } diff --git a/Sources/VocaMac/Views/MenuBarView.swift b/Sources/VocaMac/Views/MenuBarView.swift index f64e170..97b69f5 100644 --- a/Sources/VocaMac/Views/MenuBarView.swift +++ b/Sources/VocaMac/Views/MenuBarView.swift @@ -253,30 +253,28 @@ struct MenuBarView: View { .foregroundStyle(.orange) if appState.micPermission != .granted { - Button { - appState.requestMicrophonePermission() - } label: { - Label("Grant Microphone Access", systemImage: "mic.badge.xmark") - .font(.callout) - } - .buttonStyle(.plain) - .foregroundStyle(.orange) + permissionButton( + label: appState.micPermission == .denied ? "Open Microphone Settings" : "Grant Microphone Access", + icon: "mic.badge.xmark", + isDenied: appState.micPermission == .denied, + action: { appState.requestMicrophonePermission() } + ) - Text("Required to capture your voice for transcription.") + Text(appState.micPermission == .denied + ? "Denied. Enable in System Settings → Privacy & Security → Microphone." + : "Required to capture your voice for transcription.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } if appState.accessibilityPermission != .granted { - Button { - appState.requestAccessibilityPermission() - } label: { - Label("Grant Accessibility Access", systemImage: "lock.shield") - .font(.callout) - } - .buttonStyle(.plain) - .foregroundStyle(.orange) + permissionButton( + label: "Grant Accessibility Access", + icon: "lock.shield", + isDenied: appState.accessibilityPermission == .denied, + action: { appState.requestAccessibilityPermission() } + ) Text("Required for global hotkeys and text injection. Opens System Settings.") .font(.caption) @@ -285,14 +283,12 @@ struct MenuBarView: View { } if appState.inputMonitoringPermission != .granted { - Button { - appState.requestInputMonitoringPermission() - } label: { - Label("Grant Input Monitoring", systemImage: "keyboard") - .font(.callout) - } - .buttonStyle(.plain) - .foregroundStyle(.orange) + permissionButton( + label: "Grant Input Monitoring", + icon: "keyboard", + isDenied: appState.inputMonitoringPermission == .denied, + action: { appState.requestInputMonitoringPermission() } + ) Text("Required to detect hotkey presses system-wide. Enable VocaMac in the list.") .font(.caption) @@ -302,6 +298,18 @@ struct MenuBarView: View { } } + /// Reusable permission button that shows different styling for denied vs not determined + private func permissionButton(label: String, icon: String, isDenied: Bool, action: @escaping () -> Void) -> some View { + Button { + action() + } label: { + Label(label, systemImage: icon) + .font(.callout) + } + .buttonStyle(.plain) + .foregroundStyle(isDenied ? .red : .orange) + } + // MARK: - Actions private var actionsSection: some View { diff --git a/Sources/VocaMac/Views/SettingsView.swift b/Sources/VocaMac/Views/SettingsView.swift index d2ced19..eb1accc 100644 --- a/Sources/VocaMac/Views/SettingsView.swift +++ b/Sources/VocaMac/Views/SettingsView.swift @@ -144,28 +144,29 @@ struct GeneralSettingsTab: View { name: "Microphone", icon: "mic.fill", status: appState.micPermission, - action: { appState.requestMicrophonePermission() }, - actionLabel: "Grant" + action: { appState.requestMicrophonePermission() } ) PermissionRow( name: "Accessibility", icon: "accessibility", status: appState.accessibilityPermission, - action: { appState.requestAccessibilityPermission() }, - actionLabel: "Open Settings" + action: { appState.requestAccessibilityPermission() } ) PermissionRow( name: "Input Monitoring", icon: "keyboard", status: appState.inputMonitoringPermission, - action: { - appState.requestInputMonitoringPermission() - }, - actionLabel: "Open Settings" + action: { appState.requestInputMonitoringPermission() } ) + if appState.micPermission == .denied || appState.accessibilityPermission == .denied || appState.inputMonitoringPermission == .denied { + Text("Denied permissions must be enabled manually in System Settings → Privacy & Security.") + .font(.caption) + .foregroundStyle(.secondary) + } + Button("Re-check Permissions") { appState.checkPermissions() } @@ -184,7 +185,6 @@ struct PermissionRow: View { let icon: String let status: PermissionStatus let action: () -> Void - let actionLabel: String var body: some View { HStack { @@ -196,23 +196,35 @@ struct PermissionRow: View { .frame(width: 16) Text(name) Spacer() - if status == .granted { + switch status { + case .granted: Text("Granted") .font(.caption) .foregroundStyle(.green) - } else { - Button(actionLabel) { action() } + case .notDetermined: + Button("Grant") { action() } + .controlSize(.small) + case .denied: + Button("Open Settings") { action() } .controlSize(.small) } } } private var statusIcon: String { - status == .granted ? "checkmark.circle.fill" : "xmark.circle.fill" + switch status { + case .granted: return "checkmark.circle.fill" + case .notDetermined: return "questionmark.circle.fill" + case .denied: return "xmark.circle.fill" + } } private var statusColor: Color { - status == .granted ? .green : .red + switch status { + case .granted: return .green + case .notDetermined: return .orange + case .denied: return .red + } } }