Switch to NSEvent for ptt, fix Fn key bug
This commit is contained in:
parent
51ea0ebbb1
commit
07b9da74dd
@ -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 = (
|
||||
);
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user