Files
2026-10-16 14:11:30 +02:00

236 lines
8.3 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 native.register(candidate)
}
isRegistered = true
}
}
enum ShortcutError: LocalizedError {
case invalidSavedShortcut
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 .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
}
}