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 = {
/* 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 = (
);

View File

@ -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<Void, Never>?
// 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<Void, Never>?
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<HotkeyManager>.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() }
}
}

View File

@ -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) {