import AppKit import Carbon.HIToolbox /// Manages global and local hotkeys. /// /// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works /// system-wide without Accessibility permission. Tab-level hotkeys /// use a local `NSEvent` monitor (only fires when our app is active). class HotkeyManager { static let shared = HotkeyManager() // MARK: - Callbacks var onToggle: (() -> Void)? var onNewTab: (() -> Void)? var onCloseTab: (() -> Void)? var onNextTab: (() -> Void)? var onPreviousTab: (() -> Void)? var onDetachTab: (() -> Void)? var onSwitchToTab: ((Int) -> Void)? /// Tab-level hotkeys only fire when the notch is open. var isNotchOpen: Bool = false private var hotKeyRef: EventHotKeyRef? private var eventHandlerRef: EventHandlerRef? private var localMonitor: Any? private var defaultsObserver: NSObjectProtocol? private init() {} // MARK: - Resolved bindings (live from UserDefaults) private var toggleBinding: HotkeyBinding { binding(for: NotchSettings.Keys.hotkeyToggle) ?? .cmdReturn } private var newTabBinding: HotkeyBinding { binding(for: NotchSettings.Keys.hotkeyNewTab) ?? .cmdT } private var closeTabBinding: HotkeyBinding { binding(for: NotchSettings.Keys.hotkeyCloseTab) ?? .cmdW } private var nextTabBinding: HotkeyBinding { binding(for: NotchSettings.Keys.hotkeyNextTab) ?? .cmdShiftRB } private var prevTabBinding: HotkeyBinding { binding(for: NotchSettings.Keys.hotkeyPreviousTab) ?? .cmdShiftLB } private var detachBinding: HotkeyBinding { binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD } private func binding(for key: String) -> HotkeyBinding? { guard let json = UserDefaults.standard.string(forKey: key) else { return nil } return HotkeyBinding.fromJSON(json) } // MARK: - Start / Stop func start() { installCarbonHandler() registerToggleHotkey() installLocalMonitor() observeToggleHotkeyChanges() } func stop() { unregisterToggleHotkey() removeCarbonHandler() removeLocalMonitor() if let obs = defaultsObserver { NotificationCenter.default.removeObserver(obs) defaultsObserver = nil } } // MARK: - Carbon global hotkey (toggle) /// Installs a Carbon event handler that receives `kEventHotKeyPressed` /// events when a registered hotkey fires — works system-wide. private func installCarbonHandler() { var eventType = EventTypeSpec( eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed) ) // Closure must not capture self — uses the singleton accessor instead. let status = InstallEventHandler( GetApplicationEventTarget(), { (_: EventHandlerCallRef?, theEvent: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in guard let theEvent else { return OSStatus(eventNotHandledErr) } var hotKeyID = EventHotKeyID() let err = GetEventParameter( theEvent, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &hotKeyID ) guard err == noErr else { return err } if hotKeyID.id == 1 { DispatchQueue.main.async { HotkeyManager.shared.onToggle?() } } return noErr }, 1, &eventType, nil, &eventHandlerRef ) if status != noErr { print("[HotkeyManager] Failed to install Carbon event handler: \(status)") } } private func registerToggleHotkey() { unregisterToggleHotkey() let binding = toggleBinding let carbonMods = carbonModifiers(from: binding.modifiers) var hotKeyID = EventHotKeyID( signature: OSType(0x444E5452), // "DNTR" id: 1 ) let status = RegisterEventHotKey( UInt32(binding.keyCode), carbonMods, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef ) if status != noErr { print("[HotkeyManager] Failed to register toggle hotkey: \(status)") } } private func unregisterToggleHotkey() { if let ref = hotKeyRef { UnregisterEventHotKey(ref) hotKeyRef = nil } } private func removeCarbonHandler() { if let ref = eventHandlerRef { RemoveEventHandler(ref) eventHandlerRef = nil } } /// Re-register the toggle hotkey whenever the user changes it in settings. private func observeToggleHotkeyChanges() { defaultsObserver = NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in self?.registerToggleHotkey() } } // MARK: - Local monitor (tab-level hotkeys, only when our app is active) private func installLocalMonitor() { localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self else { return event } return self.handleLocalKeyEvent(event) ? nil : event } } private func removeLocalMonitor() { if let m = localMonitor { NSEvent.removeMonitor(m) localMonitor = nil } } /// Handles tab-level hotkeys. Returns true if the event was consumed. private func handleLocalKeyEvent(_ event: NSEvent) -> Bool { // Tab hotkeys only when the notch is open and focused guard isNotchOpen else { return false } if newTabBinding.matches(event) { onNewTab?() return true } if closeTabBinding.matches(event) { onCloseTab?() return true } if nextTabBinding.matches(event) { onNextTab?() return true } if prevTabBinding.matches(event) { onPreviousTab?() return true } if detachBinding.matches(event) { onDetachTab?() return true } // Cmd+1 through Cmd+9 if event.modifierFlags.contains(.command) { let digitKeyCodes: [UInt16: Int] = [ 18: 0, 19: 1, 20: 2, 21: 3, 23: 4, 22: 5, 26: 6, 28: 7, 25: 8 ] if let tabIndex = digitKeyCodes[event.keyCode] { onSwitchToTab?(tabIndex) return true } } return false } // MARK: - Carbon modifier conversion private func carbonModifiers(from nsModifiers: UInt) -> UInt32 { var carbon: UInt32 = 0 let flags = NSEvent.ModifierFlags(rawValue: nsModifiers) if flags.contains(.command) { carbon |= UInt32(cmdKey) } if flags.contains(.shift) { carbon |= UInt32(shiftKey) } if flags.contains(.option) { carbon |= UInt32(optionKey) } if flags.contains(.control) { carbon |= UInt32(controlKey) } return carbon } }