import Carbon.HIToolbox import Foundation /// Carbon's C event handler cannot capture Swift closures, so live handlers are kept in one /// process-wide table keyed by the numeric EventHotKeyID Carbon hands back on fire. Safe /// because InstallEventHandler on GetApplicationEventTarget() always delivers on the main /// thread, and every access here happens either from a @MainActor HotkeyCenter method or from /// the C callback below, which this process only ever invokes on the main run loop. private final class HotkeyDispatchTable: @unchecked Sendable { static let shared = HotkeyDispatchTable() private var handlers: [UInt32: @MainActor () -> Void] = [:] private init() {} func set(_ numericID: UInt32, _ handler: @escaping @MainActor () -> Void) { handlers[numericID] = handler } func remove(_ numericID: UInt32) { handlers.removeValue(forKey: numericID) } func fire(_ numericID: UInt32) { MainActor.assumeIsolated { handlers[numericID]?() } } } private func shotdeckCarbonHotkeyHandler( _ nextHandler: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer? ) -> OSStatus { guard let event else { return OSStatus(eventNotHandledErr) } var hotKeyID = EventHotKeyID() let status = GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &hotKeyID) guard status == noErr else { return status } HotkeyDispatchTable.shared.fire(hotKeyID.id) return noErr } /// Four-character creator code used for every `EventHotKeyID` this process registers. private let shotdeckHotKeySignature: OSType = 0x53484F54 // "SHOT" @MainActor public final class HotkeyCenter { private var bindings: [String: (ref: EventHotKeyRef, numericID: UInt32)] = [:] private var nextNumericID: UInt32 = 1 private static var didInstallGlobalCarbonHandler = false private static var carbonHandlerRef: EventHandlerRef? public init() { HotkeyCenter.installGlobalCarbonHandlerIfNeeded() } /// Registers a hotkey. `id` is a caller-chosen stable identifier. Re-registering the same /// `id` first unregisters its previous binding, then attempts the new one (idempotent). /// Returns false when Carbon's `RegisterEventHotKey` reports a non-`noErr` status — the /// most common cause is the same keyCode+modifiers combination already being claimed /// system-wide by this or another process. @discardableResult public func register( id: String, keyCode: UInt32, modifiers: UInt32, handler: @escaping @MainActor () -> Void ) -> Bool { unregister(id: id) let hotKeyID = EventHotKeyID(signature: shotdeckHotKeySignature, id: nextNumericID) var ref: EventHotKeyRef? let status = RegisterEventHotKey( keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref ) guard status == noErr, let ref else { return false } bindings[id] = (ref: ref, numericID: nextNumericID) HotkeyDispatchTable.shared.set(nextNumericID, handler) nextNumericID += 1 return true } public func unregister(id: String) { guard let binding = bindings.removeValue(forKey: id) else { return } _ = UnregisterEventHotKey(binding.ref) HotkeyDispatchTable.shared.remove(binding.numericID) } public func unregisterAll() { let ids = Array(bindings.keys) for id in ids { unregister(id: id) } } /// Installs the process-wide Carbon hot-key pressed handler exactly once. private static func installGlobalCarbonHandlerIfNeeded() { guard !didInstallGlobalCarbonHandler else { return } didInstallGlobalCarbonHandler = true var handlerRef: EventHandlerRef? var eventTypes = [ EventTypeSpec( eventClass: OSType(kEventClassKeyboard), eventKind: OSType(kEventHotKeyPressed) ) ] let status = InstallEventHandler( GetApplicationEventTarget(), shotdeckCarbonHotkeyHandler, 1, &eventTypes, nil, &handlerRef ) if status == noErr { carbonHandlerRef = handlerRef } } }