import AppKit import Carbon @MainActor public final class GlobalShortcut { public private(set) var shortcut: KeyboardShortcut? public private(set) var isRegistered = false private static let storageKey = "globalActivationShortcut" private let defaults: UserDefaults private let action: @MainActor () -> Void private let native = NativeHotKey() public init(defaults: UserDefaults = .standard, action: @escaping @MainActor () -> Void) { self.defaults = defaults self.action = action shortcut = KeyboardShortcut(storedValue: defaults.object(forKey: Self.storageKey)) native.owner = self } deinit { native.disposeOnMainThread() } public func start() throws { guard !isRegistered else { return } if let saved = defaults.object(forKey: Self.storageKey) { guard let restored = KeyboardShortcut(storedValue: saved) else { throw ShortcutError.invalidSavedShortcut } try activate(restored) shortcut = restored } } public func setShortcut(_ candidate: KeyboardShortcut?) throws { if candidate == shortcut, isRegistered { return } if let candidate { try activate(candidate) defaults.set(candidate.storedValue, forKey: Self.storageKey) } else { try native.unregister() isRegistered = false defaults.removeObject(forKey: Self.storageKey) } shortcut = candidate } public func suspend() { do { try native.unregister() isRegistered = false } catch { NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription) } } public func stop() { native.dispose() isRegistered = false } fileprivate func activateFromEvent() { action() } private func activate(_ candidate: KeyboardShortcut) throws { try Self.rejectSystemConflict(candidate) try native.register(candidate) isRegistered = true } private static func rejectSystemConflict(_ candidate: KeyboardShortcut) throws { var copied: Unmanaged? let status = CopySymbolicHotKeys(&copied) let result = copied?.takeRetainedValue() guard status == noErr, let result else { throw ShortcutError.native(operation: "read macOS keyboard shortcuts", status: status) } let hotKeys = result as NSArray for case let record as NSDictionary in hotKeys { guard let enabled = record[kHISymbolicHotKeyEnabled] as? NSNumber, enabled.boolValue, let code = record[kHISymbolicHotKeyCode] as? NSNumber, let modifiers = record[kHISymbolicHotKeyModifiers] as? NSNumber else { continue } let relevantModifiers = UInt32(cmdKey | optionKey | controlKey | shiftKey) if code.uint16Value == candidate.keyCode, modifiers.uint32Value & relevantModifiers == candidate.carbonModifiers { throw ShortcutError.systemConflict(candidate.displayName) } } } } private enum ShortcutError: LocalizedError { case invalidSavedShortcut case systemConflict(String) case native(operation: String, status: OSStatus) var errorDescription: String? { switch self { case .invalidSavedShortcut: return "The saved activation shortcut is invalid. Choose a new shortcut or clear it in Conjure Settings." case .systemConflict(let shortcut): return "\(shortcut) is already an enabled macOS shortcut. Free it in System Settings → Keyboard → Keyboard Shortcuts (check Spotlight and Input Sources), or choose another shortcut. Conjure will not change your system settings." case .native(let operation, let status): if status == eventHotKeyExistsErr { return "That shortcut is already registered by another app. Change or disable the other app’s shortcut, or choose another one. For Command-Space, also check Spotlight and Input Sources in System Settings → Keyboard → Keyboard Shortcuts." } return "Conjure could not \(operation) (macOS error \(status)). Choose another shortcut or try again." } } } // Carbon is main-thread-only. This separate lifetime lets a nonisolated Swift // deinit enqueue teardown without capturing a dying owner or dangling C context. private final class NativeHotKey: @unchecked Sendable { @MainActor weak var owner: GlobalShortcut? @MainActor private var handler: EventHandlerRef? @MainActor private var hotKey: EventHotKeyRef? @MainActor private var identifier: UInt32 = 0 @MainActor private var isPressed = false @MainActor private static var nextIdentifier: UInt32 = 0 private static let signature: OSType = 0x434E4A52 // CNJR @MainActor init() {} @MainActor func register(_ shortcut: KeyboardShortcut) throws { precondition(Thread.isMainThread) try installHandler() Self.nextIdentifier &+= 1 if Self.nextIdentifier == 0 { Self.nextIdentifier = 1 } let next = Self.nextIdentifier var replacement: EventHotKeyRef? let status = RegisterEventHotKey( UInt32(shortcut.keyCode), shortcut.carbonModifiers, EventHotKeyID(signature: Self.signature, id: next), GetApplicationEventTarget(), UInt32(kEventHotKeyExclusive), &replacement ) guard status == noErr, let replacement else { throw ShortcutError.native(operation: "register the activation shortcut", status: status) } do { try unregister() } catch { UnregisterEventHotKey(replacement) throw error } hotKey = replacement identifier = next isPressed = false } @MainActor func unregister() throws { precondition(Thread.isMainThread) guard let hotKey else { return } let status = UnregisterEventHotKey(hotKey) guard status == noErr else { throw ShortcutError.native(operation: "release the previous activation shortcut", status: status) } self.hotKey = nil identifier = 0 isPressed = false } @MainActor func dispose() { precondition(Thread.isMainThread) if let hotKey { UnregisterEventHotKey(hotKey) } hotKey = nil identifier = 0 isPressed = false if let handler { RemoveEventHandler(handler) } handler = nil } func disposeOnMainThread() { if Thread.isMainThread { MainActor.assumeIsolated { dispose() } } else { DispatchQueue.main.async { self.dispose() } } } @MainActor private func installHandler() throws { guard handler == nil else { return } var types = [ EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)), ] let status = InstallEventHandler( GetApplicationEventTarget(), { _, event, context in guard let event, let context else { return OSStatus(eventNotHandledErr) } return MainActor.assumeIsolated { Unmanaged.fromOpaque(context).takeUnretainedValue().receive(event) } }, types.count, &types, Unmanaged.passUnretained(self).toOpaque(), &handler ) guard status == noErr else { throw ShortcutError.native(operation: "install the activation shortcut handler", status: status) } } @MainActor private func receive(_ event: EventRef) -> OSStatus { precondition(Thread.isMainThread) var keyID = EventHotKeyID() let status = GetEventParameter( event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &keyID ) guard status == noErr, keyID.signature == Self.signature, hotKey != nil, keyID.id == identifier else { return OSStatus(eventNotHandledErr) } switch GetEventKind(event) { case UInt32(kEventHotKeyPressed): guard !isPressed else { return noErr } isPressed = true owner?.activateFromEvent() case UInt32(kEventHotKeyReleased): isPressed = false default: return OSStatus(eventNotHandledErr) } return noErr } }