From fdecf4ef3fd65eda53a24d81c96678ea606fbdf8 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Thu, 5 Mar 2026 18:58:03 -0800 Subject: [PATCH 1/3] fix: microphone permission never prompted on first launch Root cause: Two bugs working together: 1. AudioEngine.checkPermission() treated .notDetermined as .denied (both returned false), so AppState could never distinguish first launch from an explicit denial. 2. performStartup() called checkPermissions() but never called requestMicrophonePermission(), so the system prompt was never triggered. Changes: - AudioEngine: Replace boolean checkPermission() with tri-state checkPermissionStatus() returning PermissionStatus (.notDetermined, .granted, .denied) - AppState: checkPermissions() now preserves .notDetermined state; performStartup() auto-prompts for mic permission when notDetermined; requestMicrophonePermission() opens System Settings when denied (re-requesting won't show the prompt again) - SettingsView: PermissionRow shows 'Grant' (orange) for notDetermined, 'Open Settings' (red) for denied, with help text for denied state - MenuBarView: Popover permission buttons adapt labels and colors based on denied vs notDetermined state --- Sources/VocaMac/Models/AppState.swift | 37 +++++++++++--- Sources/VocaMac/Services/AudioEngine.swift | 12 ++--- Sources/VocaMac/Views/MenuBarView.swift | 58 ++++++++++++---------- Sources/VocaMac/Views/SettingsView.swift | 40 +++++++++------ 4 files changed, 94 insertions(+), 53 deletions(-) diff --git a/Sources/VocaMac/Models/AppState.swift b/Sources/VocaMac/Models/AppState.swift index 97c1325..2c3b2d2 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) @@ -237,6 +233,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 +248,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 @@ -469,8 +484,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 + } } } From ec96b72e0fef2a211107c7339962f0e840d86ca2 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Thu, 5 Mar 2026 19:01:36 -0800 Subject: [PATCH 2/3] fix: Input Monitoring permission check not reflecting revocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always create a fresh temporary event tap when checking Input Monitoring permission. The previous approach checked the existing HotKeyManager tap, but macOS doesn't immediately disable existing taps when the user revokes Input Monitoring — the change only takes effect on app restart. A fresh tap creation always reflects the current permission state. --- Sources/VocaMac/Models/AppState.swift | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/Sources/VocaMac/Models/AppState.swift b/Sources/VocaMac/Models/AppState.swift index 2c3b2d2..35dc596 100644 --- a/Sources/VocaMac/Models/AppState.swift +++ b/Sources/VocaMac/Models/AppState.swift @@ -205,18 +205,12 @@ final class AppState: ObservableObject { inputMonitoringPermission = inputMonitoringGranted ? .granted : .denied } - /// 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. + /// Check Input Monitoring permission by attempting to create a fresh event tap. + /// This is the most reliable method — existing taps may not reflect revocation + /// until the app restarts, but creating a new tap always reflects current state. 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 - if hotKeyManager.isListening, let tap = hotKeyManager.activeEventTap { - return CGEvent.tapIsEnabled(tap: tap) - } - - // No active tap — try creating a temporary one to test permission + // Always try creating a fresh temporary tap to test current permission state. + // Existing taps (via HotKeyManager) may not immediately reflect revocation. let tap = CGEvent.tapCreate( tap: .cgSessionEventTap, place: .headInsertEventTap, From 373489227cf365f149bc390ee1959ad3fee75dae Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Thu, 5 Mar 2026 19:07:28 -0800 Subject: [PATCH 3/3] fix: use .cghidEventTap for Input Monitoring permission checks Use .cghidEventTap (same tap type as HotKeyManager) instead of .cgSessionEventTap for permission checks and auto-registration. .cgSessionEventTap can inherit Terminal's permissions when the app is launched from CLI, giving false positives. Also restore Strategy 1: check if HotKeyManager's existing tap has been disabled by macOS (which happens on permission revocation). --- Sources/VocaMac/Models/AppState.swift | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/Sources/VocaMac/Models/AppState.swift b/Sources/VocaMac/Models/AppState.swift index 35dc596..70ce1f9 100644 --- a/Sources/VocaMac/Models/AppState.swift +++ b/Sources/VocaMac/Models/AppState.swift @@ -205,14 +205,23 @@ final class AppState: ObservableObject { inputMonitoringPermission = inputMonitoringGranted ? .granted : .denied } - /// Check Input Monitoring permission by attempting to create a fresh event tap. - /// This is the most reliable method — existing taps may not reflect revocation - /// until the app restarts, but creating a new tap always reflects current state. + /// Check Input Monitoring permission. + /// 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 { - // Always try creating a fresh temporary tap to test current permission state. - // Existing taps (via HotKeyManager) may not immediately reflect revocation. + // 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) + } + + // 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), @@ -264,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),