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
}
}
+133
View File
@@ -0,0 +1,133 @@
import AppKit
import Carbon
public struct KeyboardShortcut: Equatable, Sendable {
public let keyCode: UInt16
public let modifiers: NSEvent.ModifierFlags
public init?(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) {
let normalized = modifiers.intersection([.command, .option, .control, .shift])
guard !normalized.intersection([.command, .option, .control]).isEmpty,
Self.supports(keyCode) else { return nil }
self.keyCode = keyCode
self.modifiers = normalized
}
public static let commandSpace = KeyboardShortcut(keyCode: 49, modifiers: .command)!
@MainActor
public var displayName: String {
var result = ""
if modifiers.contains(.control) { result += "⌃" }
if modifiers.contains(.option) { result += "⌥" }
if modifiers.contains(.shift) { result += "⇧" }
if modifiers.contains(.command) { result += "⌘" }
return result + keyName
}
var carbonModifiers: UInt32 {
var result: UInt32 = 0
if modifiers.contains(.command) { result |= UInt32(cmdKey) }
if modifiers.contains(.option) { result |= UInt32(optionKey) }
if modifiers.contains(.control) { result |= UInt32(controlKey) }
if modifiers.contains(.shift) { result |= UInt32(shiftKey) }
return result
}
var storedValue: [String: Int] {
["keyCode": Int(keyCode), "modifiers": Int(modifiers.rawValue)]
}
init?(storedValue: Any?) {
guard let record = storedValue as? [String: Any],
let code = Self.storedInteger(record["keyCode"]),
let flags = Self.storedInteger(record["modifiers"]),
let keyCode = UInt16(exactly: code), flags >= 0,
let shortcut = Self(keyCode: keyCode, modifiers: .init(rawValue: UInt(flags))),
shortcut.modifiers.rawValue == UInt(flags) else { return nil }
self = shortcut
}
private static func storedInteger(_ value: Any?) -> Int? {
guard let number = value as? NSNumber,
CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
return Int(exactly: number.doubleValue)
}
private static func supports(_ keyCode: UInt16) -> Bool {
switch keyCode {
case 0...51, 53, 64, 65, 67, 69, 71, 75, 76, 78...88, 90...107, 109...111, 113...126:
return true
default:
return false
}
}
@MainActor
private var keyName: String {
switch keyCode {
case 36: return "Return"
case 48: return "Tab"
case 49: return "Space"
case 51: return "Delete"
case 53: return "Escape"
case 64: return "F17"
case 71: return "Clear"
case 76: return "Enter"
case 79: return "F18"
case 80: return "F19"
case 90: return "F20"
case 96: return "F5"
case 97: return "F6"
case 98: return "F7"
case 99: return "F3"
case 100: return "F8"
case 101: return "F9"
case 102: return "Eisu"
case 103: return "F11"
case 104: return "Kana"
case 105: return "F13"
case 106: return "F16"
case 107: return "F14"
case 109: return "F10"
case 110: return "Menu"
case 111: return "F12"
case 113: return "F15"
case 114: return "Help"
case 115: return "Home"
case 116: return "Page Up"
case 117: return "Forward Delete"
case 118: return "F4"
case 119: return "End"
case 120: return "F2"
case 121: return "Page Down"
case 122: return "F1"
case 123: return "←"
case 124: return "→"
case 125: return "↓"
case 126: return "↑"
default: return translatedKeyName ?? "Key \(keyCode)"
}
}
@MainActor
private var translatedKeyName: String? {
guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(),
let property = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
return nil
}
let data = Unmanaged<CFData>.fromOpaque(property).takeUnretainedValue()
guard let bytes = CFDataGetBytePtr(data) else { return nil }
let layout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
var deadKeyState: UInt32 = 0
var length = 0
var characters = [UniChar](repeating: 0, count: 8)
let status = UCKeyTranslate(
layout, keyCode, UInt16(kUCKeyActionDisplay), 0, UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysMask), &deadKeyState,
characters.count, &length, &characters
)
guard status == noErr, length > 0 else { return nil }
return String(utf16CodeUnits: characters, count: length).uppercased()
}
}