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