feat: add destination-specific global shortcuts

This commit is contained in:
Valdemar Lorenzen
2026-10-15 15:00:51 +02:00
parent 8946b19b7a
commit 6bb2b4a482
12 changed files with 383 additions and 53 deletions
+19 -8
View File
@@ -6,15 +6,20 @@ public final class GlobalShortcut {
public private(set) var shortcut: KeyboardShortcut?
public private(set) var isRegistered = false
private static let storageKey = "globalActivationShortcut"
private let storageKey: String
private let defaults: UserDefaults
private let action: @MainActor () -> Void
private let native = NativeHotKey()
public init(defaults: UserDefaults = .standard, action: @escaping @MainActor () -> Void) {
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: Self.storageKey))
shortcut = KeyboardShortcut(storedValue: defaults.object(forKey: storageKey))
native.owner = self
}
@@ -24,7 +29,7 @@ public final class GlobalShortcut {
public func start() throws {
guard !isRegistered else { return }
if let saved = defaults.object(forKey: Self.storageKey) {
if let saved = defaults.object(forKey: storageKey) {
guard let restored = KeyboardShortcut(storedValue: saved) else {
throw ShortcutError.invalidSavedShortcut
}
@@ -37,11 +42,11 @@ public final class GlobalShortcut {
if candidate == shortcut, isRegistered { return }
if let candidate {
try activate(candidate)
defaults.set(candidate.storedValue, forKey: Self.storageKey)
defaults.set(candidate.storedValue, forKey: storageKey)
} else {
try native.unregister()
isRegistered = false
defaults.removeObject(forKey: Self.storageKey)
defaults.removeObject(forKey: storageKey)
}
shortcut = candidate
}
@@ -91,17 +96,23 @@ public final class GlobalShortcut {
}
}
private enum ShortcutError: LocalizedError {
enum ShortcutError: LocalizedError {
case invalidSavedShortcut
case systemConflict(String)
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 activation shortcut is invalid. Choose a new shortcut or clear it in Conjure Settings."
return "The saved 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 .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."
+120
View File
@@ -0,0 +1,120 @@
import AppKit
@MainActor
public final class GlobalShortcuts {
public enum Destination: Hashable, Sendable {
case launcher
case command(Application.Command)
public var title: String {
switch self {
case .launcher: "Applications"
case .command(let command): Application.commands.first { $0.command == command }!.name
}
}
var storageKey: String {
switch self {
case .launcher: "globalActivationShortcut"
case .command(let command): "globalCommandShortcut.\(command.rawValue)"
}
}
}
public private(set) static var destinations: [Destination] =
[.launcher] + Application.Command.allCases.map { .command($0) }
public let bindings: [GlobalShortcut]
private let defaults: UserDefaults
private var isSuspended = false
public init(
defaults: UserDefaults = .standard,
action: @escaping @MainActor (Destination) -> Void
) {
self.defaults = defaults
bindings = Self.destinations.map { destination in
GlobalShortcut(defaults: defaults, storageKey: destination.storageKey) {
action(destination)
}
}
}
public func start() -> [String] {
isSuspended = false
var errors: [String] = []
for (index, binding) in bindings.enumerated() {
do {
if let saved = defaults.object(forKey: Self.destinations[index].storageKey) {
guard let candidate = KeyboardShortcut(storedValue: saved) else {
throw ShortcutError.invalidSavedShortcut
}
try validate(candidate, at: index)
}
try binding.start()
} catch {
binding.suspend()
errors.append("\(Self.destinations[index].title): \(error.localizedDescription)")
}
}
return errors
}
public func suspend() {
isSuspended = true
bindings.forEach { $0.suspend() }
}
public func stop() {
isSuspended = false
bindings.forEach { $0.stop() }
}
public func setShortcut(_ candidate: KeyboardShortcut?, at index: Int) throws {
if let candidate { try validate(candidate, at: index) }
let binding = bindings[index]
try binding.setShortcut(candidate)
if isSuspended { binding.suspend() }
}
func validate(_ candidate: KeyboardShortcut, at index: Int) throws {
if let action = Self.localAction(for: candidate, keyName: candidate.keyName) {
throw ShortcutError.inAppConflict(shortcut: candidate.displayName, action: action)
}
for (otherIndex, destination) in Self.destinations.enumerated() where otherIndex != index {
if KeyboardShortcut(storedValue: defaults.object(forKey: destination.storageKey)) == candidate {
throw ShortcutError.duplicate(shortcut: candidate.displayName, destination: destination.title)
}
}
}
// Match Conjure's menu equivalents, action buttons, and launcher/emoji panel handling.
static func localAction(for candidate: KeyboardShortcut, keyName: String) -> String? {
let key = keyName.lowercased()
if candidate.modifiers == .command {
switch key {
case ",": return "Settings"
case "h": return "Hide Conjure"
case "q": return "Quit Conjure"
case "w": return "Close Window"
case "x": return "Cut"
case "c": return "Copy"
case "v": return "Paste"
case "a": return "Select All"
case "m": return "Minimize"
case "k": return "entry actions"
case "return", "enter": return "Open, Copy, Translate, or confirm uninstall"
case "1"..."9" where key.utf8.count == 1: return "numbered application launching"
default: break
}
} else if candidate.modifiers == [.command, .option], key == "h" {
return "Hide Others"
} else if candidate.modifiers == .control {
switch key {
case "h", "j", "k", "l": return "launcher and emoji navigation"
default: break
}
}
return nil
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ public struct KeyboardShortcut: Equatable, Sendable {
}
@MainActor
private var keyName: String {
var keyName: String {
switch keyCode {
case 36: return "Return"
case 48: return "Tab"