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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user