feat: configure a persistent global launcher shortcut
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
@@ -8,7 +9,7 @@ struct ConjureApp {
|
||||
let delegate = ApplicationDelegate()
|
||||
application.delegate = delegate
|
||||
application.setActivationPolicy(.regular)
|
||||
application.mainMenu = makeMainMenu()
|
||||
application.mainMenu = makeMainMenu(delegate: delegate)
|
||||
|
||||
// NSApplication holds its delegate weakly.
|
||||
withExtendedLifetime(delegate) {
|
||||
@@ -16,7 +17,7 @@ struct ConjureApp {
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeMainMenu() -> NSMenu {
|
||||
private static func makeMainMenu(delegate: ApplicationDelegate) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let applicationItem = NSMenuItem(title: "Conjure", action: nil, keyEquivalent: "")
|
||||
let applicationMenu = NSMenu(title: "Conjure")
|
||||
@@ -28,6 +29,12 @@ struct ConjureApp {
|
||||
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
let settings = applicationMenu.addItem(
|
||||
withTitle: "Settings…",
|
||||
action: #selector(ApplicationDelegate.showSettings(_:)),
|
||||
keyEquivalent: ","
|
||||
)
|
||||
settings.target = delegate
|
||||
applicationMenu.addItem(.separator())
|
||||
applicationMenu.addItem(
|
||||
withTitle: "Hide Conjure",
|
||||
@@ -84,9 +91,16 @@ struct ConjureApp {
|
||||
@MainActor
|
||||
private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
private lazy var launcher = LauncherWindowController()
|
||||
private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() }
|
||||
private lazy var settings = SettingsWindowController(shortcut: shortcut)
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
launcher.present()
|
||||
do {
|
||||
try shortcut.start()
|
||||
} catch {
|
||||
settings.present(error: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
|
||||
@@ -94,6 +108,14 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
@objc func showSettings(_ sender: Any?) {
|
||||
settings.present()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
shortcut.stop()
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private var locationLabels: [URL: String] = [:]
|
||||
private var discoveryTask: Task<Void, Never>?
|
||||
private var isLaunching = false
|
||||
private var previousApplication: NSRunningApplication?
|
||||
|
||||
init(history: LaunchHistory = LaunchHistory()) {
|
||||
self.history = history
|
||||
@@ -36,6 +37,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
configureContent(in: window)
|
||||
window.delegate = self
|
||||
window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) }
|
||||
window.dismiss = { [weak self] in self?.dismiss() }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
@@ -43,6 +45,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
|
||||
func present() {
|
||||
let frontmost = NSWorkspace.shared.frontmostApplication
|
||||
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
|
||||
previousApplication = frontmost
|
||||
}
|
||||
searchField.stringValue = ""
|
||||
updateResults()
|
||||
window?.deminiaturize(nil)
|
||||
@@ -54,9 +60,29 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
refreshApplications()
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||
if window?.isVisible == true, window?.isKeyWindow == true {
|
||||
dismiss()
|
||||
} else {
|
||||
present()
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss(restoringFocus: Bool = true) {
|
||||
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
|
||||
let previous = previousApplication
|
||||
previousApplication = nil
|
||||
window?.orderOut(nil)
|
||||
if restoringFocus, let previous, !previous.isTerminated {
|
||||
NSApp.yieldActivation(to: previous)
|
||||
previous.activate(from: .current, options: [])
|
||||
}
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) {
|
||||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||
window?.orderOut(nil)
|
||||
dismiss(restoringFocus: false)
|
||||
}
|
||||
|
||||
private func configureContent(in window: NSWindow) {
|
||||
@@ -120,7 +146,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
tableView.target = self
|
||||
tableView.doubleAction = #selector(openSelection)
|
||||
tableView.openSelection = { [weak self] in self?.openSelection() }
|
||||
tableView.dismiss = { [weak self] in self?.window?.orderOut(nil) }
|
||||
tableView.dismiss = { [weak self] in self?.dismiss() }
|
||||
tableView.editSearch = { [weak self] event in
|
||||
guard let self, let window = self.window else { return }
|
||||
window.makeFirstResponder(self.searchField)
|
||||
@@ -295,7 +321,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
case #selector(NSResponder.insertNewline(_:)):
|
||||
openSelection()
|
||||
case #selector(NSResponder.cancelOperation(_:)):
|
||||
window?.orderOut(nil)
|
||||
dismiss()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -397,7 +423,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
do {
|
||||
try await AppLauncher.launch(application.url)
|
||||
history.recordLaunch(of: application)
|
||||
window?.orderOut(nil)
|
||||
dismiss(restoringFocus: false)
|
||||
} catch {
|
||||
let alert = NSAlert(error: error)
|
||||
alert.messageText = "Couldn’t open \(application.name)"
|
||||
@@ -445,6 +471,7 @@ private final class LauncherPanel: NSPanel {
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { true }
|
||||
var navigateSelection: ((Int) -> Void)?
|
||||
var dismiss: (() -> Void)?
|
||||
|
||||
override func sendEvent(_ event: NSEvent) {
|
||||
if event.type == .keyDown, attachedSheet == nil,
|
||||
@@ -464,7 +491,7 @@ private final class LauncherPanel: NSPanel {
|
||||
}
|
||||
|
||||
override func performClose(_ sender: Any?) {
|
||||
close()
|
||||
dismiss?()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
|
||||
@MainActor
|
||||
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
private let shortcut: GlobalShortcut
|
||||
private let recorder = ShortcutRecorderButton(title: "Record Shortcut…", target: nil, action: nil)
|
||||
private let clearButton = NSButton(title: "Clear", target: nil, action: nil)
|
||||
private let statusLabel = NSTextField(wrappingLabelWithString: "")
|
||||
private let errorLabel = NSTextField(wrappingLabelWithString: "")
|
||||
|
||||
init(shortcut: GlobalShortcut) {
|
||||
self.shortcut = shortcut
|
||||
let window = ShortcutSettingsWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 580, height: 520),
|
||||
styleMask: [.titled, .closable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Conjure Settings"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.center()
|
||||
super.init(window: window)
|
||||
window.delegate = self
|
||||
window.recorder = recorder
|
||||
configureContent(in: window)
|
||||
refresh()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Conjure creates its settings window programmatically")
|
||||
}
|
||||
|
||||
func present(error: String? = nil) {
|
||||
cancelRecording()
|
||||
if let error {
|
||||
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? error : "\(error)\n\(errorLabel.stringValue)"
|
||||
}
|
||||
refresh()
|
||||
window?.deminiaturize(nil)
|
||||
showWindow(nil)
|
||||
NSApp.activate()
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) {
|
||||
cancelRecording()
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
cancelRecording()
|
||||
}
|
||||
|
||||
private func configureContent(in window: NSWindow) {
|
||||
let heading = NSTextField(labelWithString: "Global Shortcut")
|
||||
heading.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
|
||||
|
||||
recorder.bezelStyle = .rounded
|
||||
recorder.target = self
|
||||
recorder.action = #selector(beginRecording)
|
||||
recorder.setAccessibilityLabel("Record global shortcut")
|
||||
recorder.capture = { [weak self] event in self?.capture(event) }
|
||||
recorder.cancel = { [weak self] in self?.cancelRecording() }
|
||||
recorder.modifiersChanged = { [weak self] in
|
||||
self?.errorLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels."
|
||||
}
|
||||
let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, action: #selector(useCommandSpace))
|
||||
commandSpaceButton.bezelStyle = .rounded
|
||||
commandSpaceButton.setAccessibilityLabel("Use Command Space as the global shortcut")
|
||||
clearButton.bezelStyle = .rounded
|
||||
clearButton.target = self
|
||||
clearButton.action = #selector(clearShortcut)
|
||||
clearButton.setAccessibilityLabel("Clear global shortcut")
|
||||
let controls = NSStackView(views: [recorder, commandSpaceButton, clearButton])
|
||||
controls.orientation = .horizontal
|
||||
controls.alignment = .centerY
|
||||
controls.spacing = 8
|
||||
|
||||
let instructions = NSTextField(wrappingLabelWithString:
|
||||
"Click the shortcut button, then press a key with Command, Option, or Control. Press Escape to cancel. The global shortcut is paused while recording."
|
||||
)
|
||||
instructions.textColor = .secondaryLabelColor
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
statusLabel.setAccessibilityLabel("Global shortcut registration status")
|
||||
errorLabel.textColor = .systemRed
|
||||
errorLabel.setAccessibilityLabel("Shortcut feedback")
|
||||
|
||||
let spotlight = NSTextField(wrappingLabelWithString:
|
||||
"To use ⌘Space, first free it in System Settings → Keyboard → Keyboard Shortcuts → Spotlight: turn off or change “Show Spotlight search.” Check Input Sources for other reserved shortcuts. Conjure never changes these settings."
|
||||
)
|
||||
spotlight.textColor = .secondaryLabelColor
|
||||
let keyboardSettings = NSButton(
|
||||
title: "Open Keyboard Settings…", target: self, action: #selector(openKeyboardSettings)
|
||||
)
|
||||
keyboardSettings.bezelStyle = .rounded
|
||||
|
||||
let stack = NSStackView(views: [
|
||||
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings
|
||||
])
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
let content = NSView()
|
||||
window.contentView = content
|
||||
content.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24),
|
||||
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
|
||||
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
|
||||
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24),
|
||||
recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190)
|
||||
])
|
||||
for label in [instructions, statusLabel, errorLabel, spotlight] {
|
||||
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
let name = shortcut.shortcut?.displayName
|
||||
recorder.title = recorder.isRecording ? "Type shortcut…" : (name ?? "Record Shortcut…")
|
||||
recorder.setAccessibilityValue(name ?? "Not set")
|
||||
if recorder.isRecording {
|
||||
statusLabel.stringValue = "Global shortcut paused while recording."
|
||||
} else if shortcut.isRegistered {
|
||||
statusLabel.stringValue = "Global shortcut is active."
|
||||
} else if shortcut.shortcut != nil {
|
||||
statusLabel.stringValue = "This shortcut is saved but is not active."
|
||||
} else {
|
||||
statusLabel.stringValue = "No global shortcut is configured."
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func beginRecording() {
|
||||
guard !recorder.isRecording else { return }
|
||||
errorLabel.stringValue = ""
|
||||
shortcut.suspend()
|
||||
recorder.isRecording = true
|
||||
refresh()
|
||||
if window?.makeFirstResponder(recorder) != true {
|
||||
cancelRecording()
|
||||
}
|
||||
}
|
||||
|
||||
private func capture(_ event: NSEvent) {
|
||||
guard !event.isARepeat else { return }
|
||||
if event.keyCode == 53, event.modifierFlags.intersection([.command, .option, .control]).isEmpty {
|
||||
cancelRecording()
|
||||
return
|
||||
}
|
||||
guard let candidate = KeyboardShortcut(keyCode: event.keyCode, modifiers: event.modifierFlags) else {
|
||||
recorder.isRecording = false
|
||||
errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut."
|
||||
restoreSavedShortcut()
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
apply(candidate)
|
||||
}
|
||||
|
||||
private func apply(_ candidate: KeyboardShortcut?) {
|
||||
let wasRecording = recorder.isRecording
|
||||
recorder.isRecording = false
|
||||
errorLabel.stringValue = ""
|
||||
do {
|
||||
try shortcut.setShortcut(candidate)
|
||||
} catch {
|
||||
errorLabel.stringValue = error.localizedDescription
|
||||
if wasRecording { restoreSavedShortcut() }
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func cancelRecording() {
|
||||
guard recorder.isRecording else { return }
|
||||
recorder.isRecording = false
|
||||
errorLabel.stringValue = ""
|
||||
restoreSavedShortcut()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private func restoreSavedShortcut() {
|
||||
do {
|
||||
try shortcut.setShortcut(shortcut.shortcut)
|
||||
} catch {
|
||||
let message = "Could not reactivate the saved shortcut: \(error.localizedDescription)"
|
||||
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func useCommandSpace() {
|
||||
apply(.commandSpace)
|
||||
}
|
||||
|
||||
@objc private func clearShortcut() {
|
||||
apply(nil)
|
||||
}
|
||||
|
||||
@objc private func openKeyboardSettings() {
|
||||
cancelRecording()
|
||||
let url = URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")!
|
||||
if !NSWorkspace.shared.open(url) {
|
||||
errorLabel.stringValue = "Could not open Keyboard settings. Open System Settings → Keyboard manually."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ShortcutSettingsWindow: NSWindow {
|
||||
weak var recorder: ShortcutRecorderButton?
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
if let recorder, recorder.isRecording, firstResponder === recorder {
|
||||
return recorder.performKeyEquivalent(with: event)
|
||||
}
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ShortcutRecorderButton: NSButton {
|
||||
var isRecording = false
|
||||
var capture: ((NSEvent) -> Void)?
|
||||
var cancel: (() -> Void)?
|
||||
var modifiersChanged: (() -> Void)?
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
guard isRecording, window?.firstResponder === self else {
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
keyDown(with: event)
|
||||
return true
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
guard isRecording else {
|
||||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
let activationModifiers: NSEvent.ModifierFlags = [.command, .option, .control]
|
||||
if event.keyCode == 48, event.modifierFlags.intersection(activationModifiers).isEmpty {
|
||||
if event.modifierFlags.contains(.shift) {
|
||||
window?.selectPreviousKeyView(nil)
|
||||
} else {
|
||||
window?.selectNextKeyView(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
capture?(event)
|
||||
}
|
||||
|
||||
override func flagsChanged(with event: NSEvent) {
|
||||
if isRecording { modifiersChanged?() } else { super.flagsChanged(with: event) }
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
let resigned = super.resignFirstResponder()
|
||||
if resigned, isRecording { cancel?() }
|
||||
return resigned
|
||||
}
|
||||
}
|
||||
@@ -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