import Foundation import KeyboardShortcuts import Carbon import AppKit extension KeyboardShortcuts.Name { static let toggleMiniRecorder = Self("toggleMiniRecorder") static let toggleMiniRecorder2 = Self("toggleMiniRecorder2") static let pasteLastTranscription = Self("pasteLastTranscription") } @MainActor class HotkeyManager: ObservableObject { @Published var selectedHotkey1: HotkeyOption { didSet { UserDefaults.standard.set(selectedHotkey1.rawValue, forKey: "selectedHotkey1") setupHotkeyMonitoring() } } @Published var selectedHotkey2: HotkeyOption { didSet { if selectedHotkey2 == .none { KeyboardShortcuts.setShortcut(nil, for: .toggleMiniRecorder2) } UserDefaults.standard.set(selectedHotkey2.rawValue, forKey: "selectedHotkey2") setupHotkeyMonitoring() } } private var whisperState: WhisperState private var miniRecorderShortcutManager: MiniRecorderShortcutManager // MARK: - Helper Properties private var canProcessHotkeyAction: Bool { whisperState.recordingState != .transcribing && whisperState.recordingState != .enhancing && whisperState.recordingState != .busy } // NSEvent monitoring for modifier keys private var globalEventMonitor: Any? private var localEventMonitor: Any? // Key state tracking private var currentKeyState = false private var keyPressStartTime: Date? private let briefPressThreshold = 1.7 private var isHandsFreeMode = false // Debounce for Fn key private var fnDebounceTask: Task? private var pendingFnKeyState: Bool? = nil // Keyboard shortcut state tracking private var shortcutKeyPressStartTime: Date? private var isShortcutHandsFreeMode = false private var shortcutCurrentKeyState = false private var lastShortcutTriggerTime: Date? private let shortcutCooldownInterval: TimeInterval = 0.5 enum HotkeyOption: String, CaseIterable { case none = "none" case rightOption = "rightOption" case leftOption = "leftOption" case leftControl = "leftControl" case rightControl = "rightControl" case fn = "fn" case rightCommand = "rightCommand" case rightShift = "rightShift" case custom = "custom" var displayName: String { switch self { case .none: return "None" case .rightOption: return "Right Option (⌥)" case .leftOption: return "Left Option (⌥)" case .leftControl: return "Left Control (⌃)" case .rightControl: return "Right Control (⌃)" case .fn: return "Fn" case .rightCommand: return "Right Command (⌘)" case .rightShift: return "Right Shift (⇧)" case .custom: return "Custom" } } var keyCode: CGKeyCode? { switch self { case .rightOption: return 0x3D case .leftOption: return 0x3A case .leftControl: return 0x3B case .rightControl: return 0x3E case .fn: return 0x3F case .rightCommand: return 0x36 case .rightShift: return 0x3C case .custom, .none: return nil } } var isModifierKey: Bool { return self != .custom && self != .none } } init(whisperState: WhisperState) { // One-time migration from legacy single-hotkey settings if UserDefaults.standard.object(forKey: "didMigrateHotkeys_v2") == nil { // If legacy push-to-talk modifier key was enabled, carry it over if UserDefaults.standard.bool(forKey: "isPushToTalkEnabled"), let legacyRaw = UserDefaults.standard.string(forKey: "pushToTalkKey"), let legacyKey = HotkeyOption(rawValue: legacyRaw) { UserDefaults.standard.set(legacyKey.rawValue, forKey: "selectedHotkey1") } // If a custom shortcut existed, mark hotkey-1 as custom (shortcut itself already persisted) if KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil { UserDefaults.standard.set(HotkeyOption.custom.rawValue, forKey: "selectedHotkey1") } // Leave second hotkey as .none UserDefaults.standard.set(true, forKey: "didMigrateHotkeys_v2") } // ---- normal initialisation ---- self.selectedHotkey1 = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "selectedHotkey1") ?? "") ?? .rightCommand self.selectedHotkey2 = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "selectedHotkey2") ?? "") ?? .none self.whisperState = whisperState self.miniRecorderShortcutManager = MiniRecorderShortcutManager(whisperState: whisperState) if KeyboardShortcuts.getShortcut(for: .pasteLastTranscription) == nil { let defaultPasteShortcut = KeyboardShortcuts.Shortcut(.v, modifiers: [.command, .option]) KeyboardShortcuts.setShortcut(defaultPasteShortcut, for: .pasteLastTranscription) } KeyboardShortcuts.onKeyUp(for: .pasteLastTranscription) { [weak self] in guard let self = self else { return } Task { @MainActor in LastTranscriptionService.pasteLastTranscription(from: self.whisperState.modelContext) } } Task { @MainActor in try? await Task.sleep(nanoseconds: 100_000_000) self.setupHotkeyMonitoring() } } private func setupHotkeyMonitoring() { removeAllMonitoring() setupModifierKeyMonitoring() setupCustomShortcutMonitoring() } private func setupModifierKeyMonitoring() { // Only set up if at least one hotkey is a modifier key guard (selectedHotkey1.isModifierKey && selectedHotkey1 != .none) || (selectedHotkey2.isModifierKey && selectedHotkey2 != .none) else { return } globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in guard let self = self else { return } Task { @MainActor in await self.handleModifierKeyEvent(event) } } localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in guard let self = self else { return event } Task { @MainActor in await self.handleModifierKeyEvent(event) } return event } } private func setupCustomShortcutMonitoring() { // Hotkey 1 if selectedHotkey1 == .custom { KeyboardShortcuts.onKeyDown(for: .toggleMiniRecorder) { [weak self] in Task { @MainActor in await self?.handleCustomShortcutKeyDown() } } KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder) { [weak self] in Task { @MainActor in await self?.handleCustomShortcutKeyUp() } } } // Hotkey 2 if selectedHotkey2 == .custom { KeyboardShortcuts.onKeyDown(for: .toggleMiniRecorder2) { [weak self] in Task { @MainActor in await self?.handleCustomShortcutKeyDown() } } KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder2) { [weak self] in Task { @MainActor in await self?.handleCustomShortcutKeyUp() } } } } private func removeAllMonitoring() { if let monitor = globalEventMonitor { NSEvent.removeMonitor(monitor) globalEventMonitor = nil } if let monitor = localEventMonitor { NSEvent.removeMonitor(monitor) localEventMonitor = nil } resetKeyStates() } private func resetKeyStates() { currentKeyState = false keyPressStartTime = nil isHandsFreeMode = false shortcutCurrentKeyState = false shortcutKeyPressStartTime = nil isShortcutHandsFreeMode = false } private func handleModifierKeyEvent(_ event: NSEvent) async { let keycode = event.keyCode let flags = event.modifierFlags // Determine which hotkey (if any) is being triggered let activeHotkey: HotkeyOption? if selectedHotkey1.isModifierKey && selectedHotkey1.keyCode == keycode { activeHotkey = selectedHotkey1 } else if selectedHotkey2.isModifierKey && selectedHotkey2.keyCode == keycode { activeHotkey = selectedHotkey2 } else { activeHotkey = nil } guard let hotkey = activeHotkey else { return } var isKeyPressed = false switch hotkey { case .rightOption, .leftOption: isKeyPressed = flags.contains(.option) case .leftControl, .rightControl: isKeyPressed = flags.contains(.control) case .fn: isKeyPressed = flags.contains(.function) // Debounce Fn key pendingFnKeyState = isKeyPressed fnDebounceTask?.cancel() fnDebounceTask = Task { [pendingState = isKeyPressed] in try? await Task.sleep(nanoseconds: 75_000_000) // 75ms if pendingFnKeyState == pendingState { await MainActor.run { self.processKeyPress(isKeyPressed: pendingState) } } } return case .rightCommand: isKeyPressed = flags.contains(.command) case .rightShift: isKeyPressed = flags.contains(.shift) case .custom, .none: return // Should not reach here } processKeyPress(isKeyPressed: isKeyPressed) } private func processKeyPress(isKeyPressed: Bool) { guard isKeyPressed != currentKeyState else { return } currentKeyState = isKeyPressed if isKeyPressed { keyPressStartTime = Date() if isHandsFreeMode { isHandsFreeMode = false Task { @MainActor in guard canProcessHotkeyAction else { return } await whisperState.handleToggleMiniRecorder() } return } if !whisperState.isMiniRecorderVisible { Task { @MainActor in guard canProcessHotkeyAction else { return } await whisperState.handleToggleMiniRecorder() } } } else { let now = Date() if let startTime = keyPressStartTime { let pressDuration = now.timeIntervalSince(startTime) if pressDuration < briefPressThreshold { isHandsFreeMode = true } else { Task { @MainActor in guard canProcessHotkeyAction else { return } await whisperState.handleToggleMiniRecorder() } } } keyPressStartTime = nil } } private func handleCustomShortcutKeyDown() async { if let lastTrigger = lastShortcutTriggerTime, Date().timeIntervalSince(lastTrigger) < shortcutCooldownInterval { return } guard !shortcutCurrentKeyState else { return } shortcutCurrentKeyState = true lastShortcutTriggerTime = Date() shortcutKeyPressStartTime = Date() if isShortcutHandsFreeMode { isShortcutHandsFreeMode = false guard canProcessHotkeyAction else { return } await whisperState.handleToggleMiniRecorder() return } if !whisperState.isMiniRecorderVisible { guard canProcessHotkeyAction else { return } await whisperState.handleToggleMiniRecorder() } } private func handleCustomShortcutKeyUp() async { guard shortcutCurrentKeyState else { return } shortcutCurrentKeyState = false let now = Date() if let startTime = shortcutKeyPressStartTime { let pressDuration = now.timeIntervalSince(startTime) if pressDuration < briefPressThreshold { isShortcutHandsFreeMode = true } else { guard canProcessHotkeyAction else { return } await whisperState.handleToggleMiniRecorder() } } shortcutKeyPressStartTime = nil } // Computed property for backward compatibility with UI var isShortcutConfigured: Bool { let isHotkey1Configured = (selectedHotkey1 == .custom) ? (KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil) : true let isHotkey2Configured = (selectedHotkey2 == .custom) ? (KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder2) != nil) : true return isHotkey1Configured && isHotkey2Configured } func updateShortcutStatus() { // Called when a custom shortcut changes if selectedHotkey1 == .custom || selectedHotkey2 == .custom { setupHotkeyMonitoring() } } deinit { Task { @MainActor in removeAllMonitoring() } } }