diff --git a/VoiceInk.xcodeproj/project.pbxproj b/VoiceInk.xcodeproj/project.pbxproj index 3a8aca9..923b999 100644 --- a/VoiceInk.xcodeproj/project.pbxproj +++ b/VoiceInk.xcodeproj/project.pbxproj @@ -7,8 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - E11CB51E2DB1F8AF00F9F3ED /* whisper.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E11CB51D2DB1F8AF00F9F3ED /* whisper.xcframework */; }; - E11CB51F2DB1F8AF00F9F3ED /* whisper.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E11CB51D2DB1F8AF00F9F3ED /* whisper.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + E172CAF02DB35C5300937883 /* whisper.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E136D0102DA3EE57000E1E8A /* whisper.xcframework */; }; + E172CAF12DB35C5300937883 /* whisper.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E136D0102DA3EE57000E1E8A /* whisper.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = E1A261112CC143AC00B233D1 /* KeyboardShortcuts */; }; E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD4592CC5352A00303ECB /* LaunchAtLogin */; }; E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD45E2CC544F100303ECB /* Sparkle */; }; @@ -33,13 +33,13 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - E11CB5202DB1F8AF00F9F3ED /* Embed Frameworks */ = { + E172CAF22DB35C5300937883 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( - E11CB51F2DB1F8AF00F9F3ED /* whisper.xcframework in Embed Frameworks */, + E172CAF12DB35C5300937883 /* whisper.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -80,7 +80,7 @@ E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */, E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */, E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */, - E11CB51E2DB1F8AF00F9F3ED /* whisper.xcframework in Frameworks */, + E172CAF02DB35C5300937883 /* whisper.xcframework in Frameworks */, E1F5FA7A2DA6CBF900B1FD8A /* Zip in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -142,7 +142,7 @@ E11473AC2CBE0F0A00318EE4 /* Sources */, E11473AD2CBE0F0A00318EE4 /* Frameworks */, E11473AE2CBE0F0A00318EE4 /* Resources */, - E11CB5202DB1F8AF00F9F3ED /* Embed Frameworks */, + E172CAF22DB35C5300937883 /* Embed Frameworks */, ); buildRules = ( ); diff --git a/VoiceInk/HotkeyManager.swift b/VoiceInk/HotkeyManager.swift index 24b97fa..018c6ff 100644 --- a/VoiceInk/HotkeyManager.swift +++ b/VoiceInk/HotkeyManager.swift @@ -39,10 +39,12 @@ class HotkeyManager: ObservableObject { private var whisperState: WhisperState private var currentKeyState = false - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? private var visibilityTask: Task? + // Change from single monitor to separate local and global monitors + private var globalEventMonitor: Any? + private var localEventMonitor: Any? + // Key handling properties private var keyPressStartTime: Date? private let briefPressThreshold = 1.0 // 1 second threshold for brief press @@ -52,6 +54,9 @@ class HotkeyManager: ObservableObject { private var lastShortcutTriggerTime: Date? private let shortcutCooldownInterval: TimeInterval = 0.5 // 500ms cooldown + private var fnDebounceTask: Task? + private var pendingFnKeyState: Bool? = nil + enum PushToTalkKey: String, CaseIterable { case rightOption = "rightOption" case leftOption = "leftOption" @@ -84,18 +89,6 @@ class HotkeyManager: ObservableObject { case .rightShift: return 0x3C } } - - var flags: CGEventFlags { - switch self { - case .rightOption: return .maskAlternate - case .leftOption: return .maskAlternate - case .leftControl: return .maskControl - case .rightControl: return .maskControl - case .fn: return .maskSecondaryFn - case .rightCommand: return .maskCommand - case .rightShift: return .maskShift - } - } } init(whisperState: WhisperState) { @@ -134,59 +127,87 @@ class HotkeyManager: ObservableObject { removeKeyMonitor() guard isPushToTalkEnabled else { return } - guard AXIsProcessTrusted() else { return } - let eventMask = (1 << CGEventType.flagsChanged.rawValue) + // Global monitor for capturing flags when app is in background + globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + guard let self = self else { return } + + Task { @MainActor in + await self.handleNSKeyEvent(event) + } + } - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: CGEventMask(eventMask), - callback: { proxy, type, event, refcon in - let manager = Unmanaged.fromOpaque(refcon!).takeUnretainedValue() - - if type == .flagsChanged { - Task { @MainActor in - await manager.handleKeyEvent(event) - } - } - - return Unmanaged.passRetained(event) - }, - userInfo: Unmanaged.passUnretained(self).toOpaque() - ) else { return } - - self.eventTap = tap - self.runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - - if let runLoopSource = self.runLoopSource { - CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) + // Local monitor for capturing flags when app has focus + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + guard let self = self else { return event } + + Task { @MainActor in + await self.handleNSKeyEvent(event) + } + + return event // Return the event to allow normal processing } } private func removeKeyMonitor() { - if let tap = eventTap { - CGEvent.tapEnable(tap: tap, enable: false) - if let runLoopSource = self.runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) - } - self.eventTap = nil - self.runLoopSource = nil + if let monitor = globalEventMonitor { + NSEvent.removeMonitor(monitor) + globalEventMonitor = nil + } + + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + localEventMonitor = nil } } - private func handleKeyEvent(_ event: CGEvent) async { - let flags = event.flags - let keycode = event.getIntegerValueField(.keyboardEventKeycode) + private func handleNSKeyEvent(_ event: NSEvent) async { + let keycode = event.keyCode + let flags = event.modifierFlags - let isKeyPressed = flags.contains(pushToTalkKey.flags) - let isTargetKey = pushToTalkKey == .fn ? true : keycode == pushToTalkKey.keyCode + // Check if the target key is pressed based on the modifier flags + var isKeyPressed = false + var isTargetKey = false + + switch pushToTalkKey { + case .rightOption, .leftOption: + isKeyPressed = flags.contains(.option) + isTargetKey = keycode == pushToTalkKey.keyCode + case .leftControl, .rightControl: + isKeyPressed = flags.contains(.control) + isTargetKey = keycode == pushToTalkKey.keyCode + case .fn: + isKeyPressed = flags.contains(.function) + isTargetKey = keycode == pushToTalkKey.keyCode + // Debounce only for Fn key + if isTargetKey { + pendingFnKeyState = isKeyPressed + fnDebounceTask?.cancel() + fnDebounceTask = Task { [pendingState = isKeyPressed] in + try? await Task.sleep(nanoseconds: 75_000_000) // 75ms + // Only act if the state hasn't changed during debounce + if pendingFnKeyState == pendingState { + await MainActor.run { + self.processPushToTalkKey(isKeyPressed: pendingState) + } + } + } + return + } + case .rightCommand: + isKeyPressed = flags.contains(.command) + isTargetKey = keycode == pushToTalkKey.keyCode + case .rightShift: + isKeyPressed = flags.contains(.shift) + isTargetKey = keycode == pushToTalkKey.keyCode + } guard isTargetKey else { return } + processPushToTalkKey(isKeyPressed: isKeyPressed) + } + + private func processPushToTalkKey(isKeyPressed: Bool) { guard isKeyPressed != currentKeyState else { return } - currentKeyState = isKeyPressed // Key is pressed down @@ -196,13 +217,13 @@ class HotkeyManager: ObservableObject { // If we're in hands-free mode, stop recording if isHandsFreeMode { isHandsFreeMode = false - await whisperState.handleToggleMiniRecorder() + Task { @MainActor in await whisperState.handleToggleMiniRecorder() } return } // Show recorder if not already visible if !whisperState.isMiniRecorderVisible { - await whisperState.handleToggleMiniRecorder() + Task { @MainActor in await whisperState.handleToggleMiniRecorder() } } } // Key is released @@ -219,7 +240,7 @@ class HotkeyManager: ObservableObject { // Continue recording - do nothing on release } else { // For longer presses, stop and transcribe - await whisperState.handleToggleMiniRecorder() + Task { @MainActor in await whisperState.handleToggleMiniRecorder() } } } diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index 093ad64..0304212 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -14,7 +14,6 @@ struct SettingsView: View { @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true @State private var showResetOnboardingAlert = false @State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) - @State private var hasAccessibilityPermission = AXIsProcessTrusted() var body: some View { ScrollView { @@ -67,24 +66,6 @@ struct SettingsView: View { .toggleStyle(.switch) if hotkeyManager.isPushToTalkEnabled { - if !hasAccessibilityPermission { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.red) - Text("Please enable Accessibility permissions in System Settings to use Push-to-Talk") - .settingsDescription() - .foregroundColor(.red) - } - .padding(.vertical, 4) - - Button("Open System Settings") { - NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) - } - .buttonStyle(.bordered) - .controlSize(.small) - .padding(.bottom, 4) - } - if currentShortcut == nil { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") @@ -231,19 +212,6 @@ struct SettingsView: View { .padding(.horizontal, 20) .padding(.vertical, 6) } - .onAppear { - // Check accessibility permission on appear - hasAccessibilityPermission = AXIsProcessTrusted() - - // Start observing accessibility changes - NotificationCenter.default.addObserver( - forName: NSNotification.Name("AXIsProcessTrustedChanged"), - object: nil, - queue: .main - ) { _ in - hasAccessibilityPermission = AXIsProcessTrusted() - } - } .alert("Reset Onboarding", isPresented: $showResetOnboardingAlert) { Button("Cancel", role: .cancel) { } Button("Reset", role: .destructive) {