feat: configure a persistent global launcher shortcut
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user