feat: configure a persistent global launcher shortcut

This commit is contained in:
2026-10-11 23:29:40 +02:00
parent 5842d86d9a
commit 7aed5c323c
7 changed files with 728 additions and 12 deletions
+228
View File
@@ -0,0 +1,228 @@
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<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)
}
}
}
}
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<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
}
}