260 lines
9.8 KiB
Swift
260 lines
9.8 KiB
Swift
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
|
||
}
|
||
}
|