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 var isRecording: Bool { isSuspended } init(defaults: UserDefaults, bindings: [GlobalShortcut]) { precondition(bindings.count == Self.destinations.count) self.defaults = defaults self.bindings = bindings } 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 beginRecording() { isSuspended = true bindings.forEach { $0.suspend() } } public func cancelRecording() -> [String] { guard isRecording else { return [] } return start() } public func finishRecording(_ candidate: KeyboardShortcut?, at index: Int) -> [String] { var errors: [String] = [] do { try setShortcut(candidate, at: index) } catch { errors.append(error.localizedDescription) } return errors + cancelRecording() } 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 "n": return "New Note" case "z": return "Undo" 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 == [.command, .shift], key == "z" { return "Redo" } else if candidate.modifiers == .control { switch key { case "h", "j", "k", "l": return "launcher and emoji navigation" default: break } } return nil } }