149 lines
6.4 KiB
Swift
149 lines
6.4 KiB
Swift
import Foundation
|
|
|
|
/// Represents the state of modifier keys at a given moment
|
|
struct ModifierState {
|
|
let fn: Bool
|
|
let cmd: Bool
|
|
let ctrl: Bool
|
|
let alt: Bool
|
|
let shift: Bool
|
|
}
|
|
|
|
/// Manages configured shortcuts and determines if key events should be consumed
|
|
/// Thread-safe singleton that can be updated from IOBridge (background thread)
|
|
/// and queried from event tap callback (main thread)
|
|
class ShortcutManager {
|
|
static let shared = ShortcutManager()
|
|
|
|
private var pushToTalkKeys: [String] = []
|
|
private var toggleRecordingKeys: [String] = []
|
|
|
|
// ============================================================================
|
|
// IMPORTANT: Fn Key State Tracking
|
|
// ============================================================================
|
|
// We track the Fn key state ourselves via flagsChanged events instead of
|
|
// trusting event.flags.contains(.maskSecondaryFn) on keyDown/keyUp events.
|
|
//
|
|
// WHY: macOS reports UNRELIABLE Fn flag on keyDown events, especially on
|
|
// MacBooks with the Globe/Fn key. The flag can be true even when Fn is NOT
|
|
// pressed, causing arrow keys and other keys to be incorrectly consumed.
|
|
//
|
|
// FIX: We update fnKeyDown only when we receive flagsChanged events (which
|
|
// are reliable for modifier state), and use this tracked state for shortcut
|
|
// matching in shouldConsumeKey().
|
|
// ============================================================================
|
|
private var fnKeyDown: Bool = false
|
|
|
|
// ============================================================================
|
|
// Non-Modifier Key State Tracking
|
|
// ============================================================================
|
|
// We track currently pressed non-modifier keys across keyDown/keyUp events.
|
|
// This is necessary for multi-key shortcuts like Shift+A+B where we need to
|
|
// know that 'A' is still held when 'B' is pressed.
|
|
//
|
|
// WARNING: pressedRegularKeys can get stuck if keyUp events are missed
|
|
// (e.g., event tap disabled by timeout, sleep/wake cycles, accessibility
|
|
// permission changes). This will cause shortcuts to stop matching because
|
|
// activeKeys retains extra keys. Consider clearing this state on:
|
|
// - flagsChanged showing all modifiers released
|
|
// - App/tap re-initialization
|
|
// - Sleep/wake notifications
|
|
// ============================================================================
|
|
private var pressedRegularKeys = Set<String>()
|
|
|
|
private let lock = NSLock()
|
|
private let dateFormatter: DateFormatter
|
|
|
|
private init() {
|
|
self.dateFormatter = DateFormatter()
|
|
self.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
|
}
|
|
|
|
private func logToStderr(_ message: String) {
|
|
let timestamp = dateFormatter.string(from: Date())
|
|
let logMessage = "[\(timestamp)] \(message)\n"
|
|
FileHandle.standardError.write(logMessage.data(using: .utf8)!)
|
|
}
|
|
|
|
/// Update the configured shortcuts
|
|
/// Called from IOBridge when setShortcuts RPC is received
|
|
func setShortcuts(pushToTalk: [String], toggleRecording: [String]) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
self.pushToTalkKeys = pushToTalk
|
|
self.toggleRecordingKeys = toggleRecording
|
|
logToStderr("[ShortcutManager] Shortcuts updated - PTT: \(pushToTalk), Toggle: \(toggleRecording)")
|
|
}
|
|
|
|
/// Update the tracked Fn key state
|
|
/// Called from event tap callback when flagsChanged event is received
|
|
/// We track Fn separately because macOS can report unreliable Fn flag on keyDown events
|
|
func setFnKeyState(_ isDown: Bool) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
fnKeyDown = isDown
|
|
}
|
|
|
|
/// Add a regular (non-modifier) key to the tracked set
|
|
/// Called from event tap callback on keyDown events
|
|
func addRegularKey(_ key: String) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
pressedRegularKeys.insert(key)
|
|
}
|
|
|
|
/// Remove a regular (non-modifier) key from the tracked set
|
|
/// Called from event tap callback on keyUp events
|
|
func removeRegularKey(_ key: String) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
pressedRegularKeys.remove(key)
|
|
}
|
|
|
|
/// Check if this key event should be consumed (prevent default behavior)
|
|
/// Called from event tap callback for keyDown/keyUp events only
|
|
func shouldConsumeKey(keyCode: Int, modifiers: ModifierState) -> Bool {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
|
|
// Early exit if no shortcuts configured
|
|
if pushToTalkKeys.isEmpty && toggleRecordingKeys.isEmpty {
|
|
return false
|
|
}
|
|
|
|
// If we can't map this key, don't consume it - prevents unmapped keys
|
|
// (like PageUp, Home) from being incorrectly consumed when a modifier is held
|
|
guard let currentKeyName = keyCodeToName(keyCode) else {
|
|
return false
|
|
}
|
|
|
|
// Build set of currently active modifier keys
|
|
// Note: We use tracked fnKeyDown instead of modifiers.fn because macOS
|
|
// can report unreliable Fn flag on keyDown events (especially on MacBooks)
|
|
var activeModifiers = Set<String>()
|
|
if fnKeyDown { activeModifiers.insert("Fn") }
|
|
if modifiers.cmd { activeModifiers.insert("Cmd") }
|
|
if modifiers.ctrl { activeModifiers.insert("Ctrl") }
|
|
if modifiers.alt { activeModifiers.insert("Alt") }
|
|
if modifiers.shift { activeModifiers.insert("Shift") }
|
|
|
|
// Build full set of active keys (modifiers + tracked regular keys + current key)
|
|
var activeKeys = activeModifiers
|
|
activeKeys.formUnion(pressedRegularKeys)
|
|
activeKeys.insert(currentKeyName)
|
|
|
|
// PTT: consume if building toward the shortcut
|
|
// - At least one modifier from the shortcut must be held (signals intent)
|
|
// - All currently pressed keys must be part of the shortcut (activeKeys ⊆ pttKeys)
|
|
let pttKeys = Set(pushToTalkKeys)
|
|
let pttModifiers = pttKeys.intersection(["Fn", "Cmd", "Ctrl", "Alt", "Shift"])
|
|
let hasRequiredModifier = !pttModifiers.isEmpty && !pttModifiers.isDisjoint(with: activeModifiers)
|
|
let pttMatch = !pttKeys.isEmpty && hasRequiredModifier && activeKeys.isSubset(of: pttKeys)
|
|
|
|
// Toggle: exact match (only these keys pressed)
|
|
let toggleKeys = Set(toggleRecordingKeys)
|
|
let toggleMatch = !toggleKeys.isEmpty && toggleKeys == activeKeys
|
|
|
|
return pttMatch || toggleMatch
|
|
}
|
|
}
|