Switch to NSEvent for ptt, fix Fn key bug

This commit is contained in:
Beingpax 2025-04-19 12:49:17 +05:45
parent 51ea0ebbb1
commit 07b9da74dd
3 changed files with 84 additions and 95 deletions

View File

@ -7,8 +7,8 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
E11CB51E2DB1F8AF00F9F3ED /* whisper.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E11CB51D2DB1F8AF00F9F3ED /* whisper.xcframework */; }; E172CAF02DB35C5300937883 /* whisper.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E136D0102DA3EE57000E1E8A /* whisper.xcframework */; };
E11CB51F2DB1F8AF00F9F3ED /* whisper.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E11CB51D2DB1F8AF00F9F3ED /* whisper.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 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 */; }; E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = E1A261112CC143AC00B233D1 /* KeyboardShortcuts */; };
E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD4592CC5352A00303ECB /* LaunchAtLogin */; }; E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD4592CC5352A00303ECB /* LaunchAtLogin */; };
E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD45E2CC544F100303ECB /* Sparkle */; }; E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD45E2CC544F100303ECB /* Sparkle */; };
@ -33,13 +33,13 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
E11CB5202DB1F8AF00F9F3ED /* Embed Frameworks */ = { E172CAF22DB35C5300937883 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase; isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
dstPath = ""; dstPath = "";
dstSubfolderSpec = 10; dstSubfolderSpec = 10;
files = ( files = (
E11CB51F2DB1F8AF00F9F3ED /* whisper.xcframework in Embed Frameworks */, E172CAF12DB35C5300937883 /* whisper.xcframework in Embed Frameworks */,
); );
name = "Embed Frameworks"; name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -80,7 +80,7 @@
E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */, E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */,
E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */, E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */,
E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */, E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */,
E11CB51E2DB1F8AF00F9F3ED /* whisper.xcframework in Frameworks */, E172CAF02DB35C5300937883 /* whisper.xcframework in Frameworks */,
E1F5FA7A2DA6CBF900B1FD8A /* Zip in Frameworks */, E1F5FA7A2DA6CBF900B1FD8A /* Zip in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -142,7 +142,7 @@
E11473AC2CBE0F0A00318EE4 /* Sources */, E11473AC2CBE0F0A00318EE4 /* Sources */,
E11473AD2CBE0F0A00318EE4 /* Frameworks */, E11473AD2CBE0F0A00318EE4 /* Frameworks */,
E11473AE2CBE0F0A00318EE4 /* Resources */, E11473AE2CBE0F0A00318EE4 /* Resources */,
E11CB5202DB1F8AF00F9F3ED /* Embed Frameworks */, E172CAF22DB35C5300937883 /* Embed Frameworks */,
); );
buildRules = ( buildRules = (
); );

View File

@ -39,10 +39,12 @@ class HotkeyManager: ObservableObject {
private var whisperState: WhisperState private var whisperState: WhisperState
private var currentKeyState = false private var currentKeyState = false
private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var visibilityTask: Task<Void, Never>? private var visibilityTask: Task<Void, Never>?
// Change from single monitor to separate local and global monitors
private var globalEventMonitor: Any?
private var localEventMonitor: Any?
// Key handling properties // Key handling properties
private var keyPressStartTime: Date? private var keyPressStartTime: Date?
private let briefPressThreshold = 1.0 // 1 second threshold for brief press private let briefPressThreshold = 1.0 // 1 second threshold for brief press
@ -52,6 +54,9 @@ class HotkeyManager: ObservableObject {
private var lastShortcutTriggerTime: Date? private var lastShortcutTriggerTime: Date?
private let shortcutCooldownInterval: TimeInterval = 0.5 // 500ms cooldown private let shortcutCooldownInterval: TimeInterval = 0.5 // 500ms cooldown
private var fnDebounceTask: Task<Void, Never>?
private var pendingFnKeyState: Bool? = nil
enum PushToTalkKey: String, CaseIterable { enum PushToTalkKey: String, CaseIterable {
case rightOption = "rightOption" case rightOption = "rightOption"
case leftOption = "leftOption" case leftOption = "leftOption"
@ -84,18 +89,6 @@ class HotkeyManager: ObservableObject {
case .rightShift: return 0x3C 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) { init(whisperState: WhisperState) {
@ -134,59 +127,87 @@ class HotkeyManager: ObservableObject {
removeKeyMonitor() removeKeyMonitor()
guard isPushToTalkEnabled else { return } 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( // Local monitor for capturing flags when app has focus
tap: .cgSessionEventTap, localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
place: .headInsertEventTap, guard let self = self else { return event }
options: .defaultTap,
eventsOfInterest: CGEventMask(eventMask), Task { @MainActor in
callback: { proxy, type, event, refcon in await self.handleNSKeyEvent(event)
let manager = Unmanaged<HotkeyManager>.fromOpaque(refcon!).takeUnretainedValue() }
if type == .flagsChanged { return event // Return the event to allow normal processing
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)
} }
} }
private func removeKeyMonitor() { private func removeKeyMonitor() {
if let tap = eventTap { if let monitor = globalEventMonitor {
CGEvent.tapEnable(tap: tap, enable: false) NSEvent.removeMonitor(monitor)
if let runLoopSource = self.runLoopSource { globalEventMonitor = nil
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) }
}
self.eventTap = nil if let monitor = localEventMonitor {
self.runLoopSource = nil NSEvent.removeMonitor(monitor)
localEventMonitor = nil
} }
} }
private func handleKeyEvent(_ event: CGEvent) async { private func handleNSKeyEvent(_ event: NSEvent) async {
let flags = event.flags let keycode = event.keyCode
let keycode = event.getIntegerValueField(.keyboardEventKeycode) let flags = event.modifierFlags
let isKeyPressed = flags.contains(pushToTalkKey.flags) // Check if the target key is pressed based on the modifier flags
let isTargetKey = pushToTalkKey == .fn ? true : keycode == pushToTalkKey.keyCode 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 } guard isTargetKey else { return }
processPushToTalkKey(isKeyPressed: isKeyPressed)
}
private func processPushToTalkKey(isKeyPressed: Bool) {
guard isKeyPressed != currentKeyState else { return } guard isKeyPressed != currentKeyState else { return }
currentKeyState = isKeyPressed currentKeyState = isKeyPressed
// Key is pressed down // Key is pressed down
@ -196,13 +217,13 @@ class HotkeyManager: ObservableObject {
// If we're in hands-free mode, stop recording // If we're in hands-free mode, stop recording
if isHandsFreeMode { if isHandsFreeMode {
isHandsFreeMode = false isHandsFreeMode = false
await whisperState.handleToggleMiniRecorder() Task { @MainActor in await whisperState.handleToggleMiniRecorder() }
return return
} }
// Show recorder if not already visible // Show recorder if not already visible
if !whisperState.isMiniRecorderVisible { if !whisperState.isMiniRecorderVisible {
await whisperState.handleToggleMiniRecorder() Task { @MainActor in await whisperState.handleToggleMiniRecorder() }
} }
} }
// Key is released // Key is released
@ -219,7 +240,7 @@ class HotkeyManager: ObservableObject {
// Continue recording - do nothing on release // Continue recording - do nothing on release
} else { } else {
// For longer presses, stop and transcribe // For longer presses, stop and transcribe
await whisperState.handleToggleMiniRecorder() Task { @MainActor in await whisperState.handleToggleMiniRecorder() }
} }
} }

View File

@ -14,7 +14,6 @@ struct SettingsView: View {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true
@State private var showResetOnboardingAlert = false @State private var showResetOnboardingAlert = false
@State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) @State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder)
@State private var hasAccessibilityPermission = AXIsProcessTrusted()
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -67,24 +66,6 @@ struct SettingsView: View {
.toggleStyle(.switch) .toggleStyle(.switch)
if hotkeyManager.isPushToTalkEnabled { 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 { if currentShortcut == nil {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
@ -231,19 +212,6 @@ struct SettingsView: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 6) .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) { .alert("Reset Onboarding", isPresented: $showResetOnboardingAlert) {
Button("Cancel", role: .cancel) { } Button("Cancel", role: .cancel) { }
Button("Reset", role: .destructive) { Button("Reset", role: .destructive) {