import AppKit import Carbon.HIToolbox import Combine /// Manages global and local hotkeys. /// /// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works /// system-wide without Accessibility permission. Notch-scoped hotkeys /// use a local `NSEvent` monitor (only fires when our app is active). @MainActor class HotkeyManager { static let shared = HotkeyManager() // MARK: - Callbacks var onToggle: (() -> Void)? var onNewTab: (() -> Void)? var onCloseTab: (() -> Void)? var onNextTab: (() -> Void)? var onPreviousTab: (() -> Void)? var onNextWorkspace: (() -> Void)? var onPreviousWorkspace: (() -> Void)? var onDetachTab: (() -> Void)? var onApplySizePreset: ((TerminalSizePreset) -> Void)? var onSwitchToTab: ((Int) -> Void)? var onSwitchToWorkspace: ((WorkspaceID) -> Void)? /// Notch-scoped 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 let settingsProvider: TerminalSessionConfigurationProviding private let workspaceRegistry: WorkspaceRegistry private var settingsCancellable: AnyCancellable? init( settingsProvider: TerminalSessionConfigurationProviding? = nil, workspaceRegistry: WorkspaceRegistry? = nil ) { self.settingsProvider = settingsProvider ?? AppSettingsController.shared self.workspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared } // MARK: - Resolved bindings from typed runtime settings private var toggleBinding: HotkeyBinding { settingsProvider.hotkeySettings.toggle } private var newTabBinding: HotkeyBinding { settingsProvider.hotkeySettings.newTab } private var closeTabBinding: HotkeyBinding { settingsProvider.hotkeySettings.closeTab } private var nextTabBinding: HotkeyBinding { settingsProvider.hotkeySettings.nextTab } private var prevTabBinding: HotkeyBinding { settingsProvider.hotkeySettings.previousTab } private var nextWorkspaceBinding: HotkeyBinding { settingsProvider.hotkeySettings.nextWorkspace } private var previousWorkspaceBinding: HotkeyBinding { settingsProvider.hotkeySettings.previousWorkspace } private var detachBinding: HotkeyBinding { settingsProvider.hotkeySettings.detachTab } private var sizePresets: [TerminalSizePreset] { settingsProvider.terminalSizePresets } // MARK: - Start / Stop func start() { installCarbonHandler() registerToggleHotkey() installLocalMonitor() observeToggleHotkeyChanges() } func stop() { unregisterToggleHotkey() removeCarbonHandler() removeLocalMonitor() settingsCancellable = 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) let 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 typed settings change. private func observeToggleHotkeyChanges() { guard let settingsProvider = settingsProvider as? AppSettingsController else { return } settingsCancellable = settingsProvider.$settings .map(\.hotkeys.toggle) .removeDuplicates() .dropFirst() .sink { [weak self] _ in self?.registerToggleHotkey() } } // MARK: - Local monitor (notch-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 notch-scoped hotkeys. Returns true if the event was consumed. private func handleLocalKeyEvent(_ event: NSEvent) -> Bool { // Local shortcuts only fire 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 nextWorkspaceBinding.matches(event) { onNextWorkspace?() return true } if previousWorkspaceBinding.matches(event) { onPreviousWorkspace?() return true } if detachBinding.matches(event) { onDetachTab?() return true } for summary in workspaceRegistry.workspaceSummaries { guard let binding = summary.hotkey else { continue } if binding.matches(event) { onSwitchToWorkspace?(summary.id) return true } } for preset in sizePresets { guard let binding = preset.hotkey else { continue } if binding.matches(event) { onApplySizePreset?(preset) 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 } }