242 lines
7.4 KiB
Swift
242 lines
7.4 KiB
Swift
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<EventHotKeyID>.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
|
|
}
|
|
}
|