Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 41 additions & 16 deletions Sources/VocaMac/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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),
Expand All @@ -237,13 +236,32 @@ 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
}
}
}

/// 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
Expand All @@ -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),
Expand Down Expand Up @@ -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)...")
Expand Down
12 changes: 6 additions & 6 deletions Sources/VocaMac/Services/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
58 changes: 33 additions & 25 deletions Sources/VocaMac/Views/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
40 changes: 26 additions & 14 deletions Sources/VocaMac/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -184,7 +185,6 @@ struct PermissionRow: View {
let icon: String
let status: PermissionStatus
let action: () -> Void
let actionLabel: String

var body: some View {
HStack {
Expand All @@ -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
}
}
}

Expand Down