Files
conjure/Sources/ConjureKit/GlobalShortcut.swift
T
2026-10-16 12:06:41 +02:00

260 lines
9.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Carbon
@MainActor
public final class GlobalShortcut {
public private(set) var shortcut: KeyboardShortcut?
public private(set) var isRegistered = false
private let storageKey: String
private let defaults: UserDefaults
private let action: @MainActor () -> Void
private let native = NativeHotKey()
private var registration: Registration?
struct Registration {
var activate: (KeyboardShortcut) throws -> Void
var unregister: () throws -> Void
var stop: () -> Void
}
convenience init(defaults: UserDefaults, storageKey: String, registration: Registration) {
self.init(defaults: defaults, storageKey: storageKey, action: {})
self.registration = registration
}
public init(
defaults: UserDefaults = .standard,
storageKey: String = "globalActivationShortcut",
action: @escaping @MainActor () -> Void
) {
self.defaults = defaults
self.storageKey = storageKey
self.action = action
shortcut = KeyboardShortcut(storedValue: defaults.object(forKey: storageKey))
native.owner = self
}
deinit {
native.disposeOnMainThread()
}
public func start() throws {
guard !isRegistered else { return }
if let saved = defaults.object(forKey: 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: storageKey)
} else {
try unregister()
isRegistered = false
defaults.removeObject(forKey: storageKey)
}
shortcut = candidate
}
public func suspend() {
do {
try unregister()
isRegistered = false
} catch {
NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription)
}
}
public func stop() {
if let registration { registration.stop() } else { native.dispose() }
isRegistered = false
}
fileprivate func activateFromEvent() {
action()
}
private func unregister() throws {
if let registration { try registration.unregister() } else { try native.unregister() }
}
private func activate(_ candidate: KeyboardShortcut) throws {
if let registration {
try registration.activate(candidate)
} else {
try Self.rejectSystemConflict(candidate)
try native.register(candidate)
}
isRegistered = true
}
private static func rejectSystemConflict(_ candidate: KeyboardShortcut) throws {
var copied: Unmanaged<CFArray>?
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)
}
}
}
}
enum ShortcutError: LocalizedError {
case invalidSavedShortcut
case systemConflict(String)
case inAppConflict(shortcut: String, action: String)
case duplicate(shortcut: String, destination: String)
case native(operation: String, status: OSStatus)
var errorDescription: String? {
switch self {
case .invalidSavedShortcut:
return "The saved 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. Check System Settings → Keyboard → Keyboard Shortcuts, including Spotlight and Input Sources. On macOS 27, Command-Shift-Space is also reserved for Siri AI screen capture. Choose another shortcut or change the system binding; Conjure will not change your system settings."
case .inAppConflict(let shortcut, let action):
return "\(shortcut) is already used by Conjure for \(action). Choose another shortcut."
case .duplicate(let shortcut, let destination):
return "\(shortcut) is already assigned to \(destination). Clear that binding or choose another shortcut."
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<NativeHotKey>.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<EventHotKeyID>.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
}
}