From 6bb2b4a48230d2227c26a2cd881b9ccbbcec3be1 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Thu, 15 Oct 2026 15:00:51 +0200 Subject: [PATCH] feat: add destination-specific global shortcuts --- README.md | 8 +- Sources/Conjure/ConjureApp.swift | 22 ++-- Sources/Conjure/EmojiPickerController.swift | 4 + .../Conjure/LauncherWindowController.swift | 40 ++++-- .../Conjure/SettingsWindowController.swift | 53 +++++--- Sources/ConjureKit/GlobalShortcut.swift | 27 ++-- Sources/ConjureKit/GlobalShortcuts.swift | 120 +++++++++++++++++ Sources/ConjureKit/KeyboardShortcut.swift | 2 +- TODO.md | 1 + .../GlobalShortcutsTests.swift | 121 ++++++++++++++++++ .../ShortcutDestinationTests.swift | 34 +++++ Tests/ConjureKitTests/ToolFeedbackTests.swift | 4 +- 12 files changed, 383 insertions(+), 53 deletions(-) create mode 100644 Sources/ConjureKit/GlobalShortcuts.swift create mode 100644 Tests/ConjureKitTests/GlobalShortcutsTests.swift create mode 100644 Tests/ConjureKitTests/ShortcutDestinationTests.swift diff --git a/README.md b/README.md index 5003838..b6af615 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,13 @@ Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reo Right-click the launcher background for Settings and Quit. Actions include favorites, resetting usage ranking, revealing applications in Finder, quitting them, and uninstalling them. -### Global shortcut and launch at login +### Global shortcuts and launch at login -In Settings, choose **Record Shortcut…** and press a combination containing Command, Option, or Control. **Clear** disables it. No global shortcut is assigned by default. +In Settings, choose a **Global shortcut destination**: **Applications**, **Conjure Emoji**, or **Conjure Translate**. Choose **Record Shortcut…** and press a combination containing Command, Option, or Control. For example, select **Conjure Emoji** and record Command-Shift-Space to open the emoji picker directly. **Clear** disables only the selected destination's shortcut. No shortcuts are assigned by default; existing launcher bindings are preserved. -For Command-Space, choose **Use ⌘Space**. If it conflicts with Spotlight, Input Sources, or another app, change that binding in System Settings first. Conjure never changes macOS shortcuts for you. The shortcut toggles the launcher while Conjure is running; it does not start a quit app or require Accessibility permission. +The Applications shortcut always targets the application launcher, not the last-used tool. Command shortcuts open their selected tool directly. Pressing a shortcut while its destination is already frontmost dismisses Conjure; switching destinations opens a fresh view. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation. + +For Command-Space, select **Applications** and choose **Use ⌘Space**. If it conflicts with Spotlight, Input Sources, or another app, change that binding in System Settings first. Conjure never changes macOS shortcuts for you. Shortcuts work while Conjure is running; they do not start a quit app or require Accessibility permission. Install Conjure in `/Applications`, then enable **Launch at login** in Settings to start it in the background. If macOS requires approval, use **Open Login Items Settings…** to grant it. diff --git a/Sources/Conjure/ConjureApp.swift b/Sources/Conjure/ConjureApp.swift index b47456d..2681e81 100644 --- a/Sources/Conjure/ConjureApp.swift +++ b/Sources/Conjure/ConjureApp.swift @@ -92,8 +92,13 @@ struct ConjureApp { private final class ApplicationDelegate: NSObject, NSApplicationDelegate { private let uninstaller = ApplicationUninstaller() private lazy var launcher = LauncherWindowController(uninstaller: uninstaller) - private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() } - private lazy var settings = SettingsWindowController(shortcut: shortcut) + private lazy var shortcuts = GlobalShortcuts { [weak self] destination in + switch destination { + case .launcher: self?.launcher.toggle() + case .command(let command): self?.launcher.toggle(command: command) + } + } + private lazy var settings = SettingsWindowController(shortcuts: shortcuts) private var startupShortcutError: String? private var startupRecoveryMessage: String? @@ -123,13 +128,10 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate { if !isLoginLaunch { launcher.present() } - do { - try shortcut.start() - } catch { - startupShortcutError = error.localizedDescription - if !isLoginLaunch { - showSettings(nil) - } + let shortcutErrors = shortcuts.start() + if !shortcutErrors.isEmpty { + startupShortcutError = shortcutErrors.joined(separator: "\n") + if !isLoginLaunch { showSettings(nil) } } } @@ -149,7 +151,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { - shortcut.stop() + shortcuts.stop() } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { diff --git a/Sources/Conjure/EmojiPickerController.swift b/Sources/Conjure/EmojiPickerController.swift index 6f03627..591e632 100644 --- a/Sources/Conjure/EmojiPickerController.swift +++ b/Sources/Conjure/EmojiPickerController.swift @@ -111,6 +111,10 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, @objc func goBack() { if actions.isShown { actions.close() } else { closed?() } } + func close() { + actions.close() + closed?() + } override func cancelOperation(_ sender: Any?) { goBack() } func inputChanged(preservingSelection: Bool = false) { diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index df290e8..0ff74f6 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -36,6 +36,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe private var translation: TranslationController? private var emojiPicker: EmojiPickerController? private var isCommandOpen: Bool { translation != nil || emojiPicker != nil } + private var activeCommand: Application.Command? + private var pendingPresentation: (() -> Void)? init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) { self.history = history @@ -100,15 +102,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe currencyTask?.cancel() } - func present() { - (window as? LauncherPanel)?.beginNumberHintSession() - if launcherFrameBeforeReview != nil || isCommandOpen { - showWindow(nil) - NSApp.activate() - window?.makeKeyAndOrderFront(nil) - (window as? LauncherPanel)?.sampleNumberHints() + func present(command: Application.Command? = nil) { + guard !isLaunching, window?.attachedSheet == nil else { return } + if launcherFrameBeforeReview != nil { + pendingPresentation = { [weak self] in self?.present(command: command) } + uninstallReview?.goBack() return } + closeCommand() + actions.close() + (window as? LauncherPanel)?.beginNumberHintSession() let frontmost = NSWorkspace.shared.frontmostApplication if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier { previousApplication = frontmost @@ -124,14 +127,21 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe (window as? LauncherPanel)?.sampleNumberHints() (searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent refreshApplications() + if let command { + showCommand(command) + history.recordLaunch(of: Application(command: command)) + } } - func toggle() { + func toggle(command: Application.Command? = nil) { guard !isLaunching, window?.attachedSheet == nil else { return } - if window?.isVisible == true, window?.isKeyWindow == true { + if window?.isVisible == true, window?.isKeyWindow == true, + launcherFrameBeforeReview == nil, activeCommand == command { + closeCommand() + actions.close() dismiss() } else { - present() + present(command: command) } } @@ -728,6 +738,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe self.openButton.keyEquivalent = "\r" self.updateOpenButton() self.window?.makeFirstResponder(self.searchField) + let pending = self.pendingPresentation + self.pendingPresentation = nil + pending?() } } } @@ -790,8 +803,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe emojiPicker?.goBack() } + private func closeCommand() { + translation?.goBack() + emojiPicker?.close() + } + private func showCommand(_ command: Application.Command) { guard !isCommandOpen, let content = window?.contentView else { return } + activeCommand = command let controller: NSViewController let placeholder: String let accessibilityLabel: String @@ -820,6 +839,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe controller.view.removeFromSuperview() self.translation = nil self.emojiPicker = nil + self.activeCommand = nil self.commandBack.isHidden = true self.commandBack.target = nil self.searchSymbol.isHidden = false diff --git a/Sources/Conjure/SettingsWindowController.swift b/Sources/Conjure/SettingsWindowController.swift index 89fdb8d..cc50302 100644 --- a/Sources/Conjure/SettingsWindowController.swift +++ b/Sources/Conjure/SettingsWindowController.swift @@ -4,7 +4,10 @@ import ServiceManagement @MainActor final class SettingsWindowController: NSWindowController, NSWindowDelegate { - private let shortcut: GlobalShortcut + private let shortcuts: GlobalShortcuts + private let destinationSelector = NSPopUpButton(frame: .zero, pullsDown: false) + private let commandSpaceButton = NSButton(title: "Use ⌘Space", target: nil, action: nil) + private var shortcut: GlobalShortcut { shortcuts.bindings[destinationSelector.indexOfSelectedItem] } 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: "") @@ -17,10 +20,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private var recoveryReport: UninstallReport? var retryUninstallRecovery: (() async -> UninstallReport)? - init(shortcut: GlobalShortcut) { - self.shortcut = shortcut + init(shortcuts: GlobalShortcuts) { + self.shortcuts = shortcuts let window = ShortcutSettingsWindow( - contentRect: NSRect(x: 0, y: 0, width: 620, height: 640), + contentRect: NSRect(x: 0, y: 0, width: 620, height: 690), styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], backing: .buffered, defer: false @@ -69,9 +72,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } private func configureContent(in window: NSWindow) { - let heading = NSTextField(labelWithString: "Global Shortcut") + let heading = NSTextField(labelWithString: "Global Shortcuts") heading.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + destinationSelector.addItems(withTitles: GlobalShortcuts.destinations.map(\.title)) + destinationSelector.target = self + destinationSelector.action = #selector(changeDestination) + destinationSelector.setAccessibilityLabel("Global shortcut destination") + recorder.bezelStyle = .rounded recorder.target = self recorder.action = #selector(beginRecording) @@ -82,7 +90,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { self?.errorLabel.stringValue = "" self?.statusLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels." } - let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, action: #selector(useCommandSpace)) + commandSpaceButton.target = self + commandSpaceButton.action = #selector(useCommandSpace) commandSpaceButton.bezelStyle = .rounded commandSpaceButton.setAccessibilityLabel("Use Command Space as the global shortcut") clearButton.bezelStyle = .rounded @@ -95,7 +104,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { 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." + "Choose a destination, click the shortcut button, then press a key with Command, Option, or Control. Press Escape to cancel. All global shortcuts are paused while recording." ) instructions.textColor = .secondaryLabelColor statusLabel.textColor = .secondaryLabelColor @@ -139,7 +148,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { loginHeading.textColor = SunsetPalette.text let stack = NSStackView(views: [ - heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings, + heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings, loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton ]) stack.orientation = .vertical @@ -240,11 +249,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private func refresh() { refreshLogin() + commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0 + recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut") 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." + statusLabel.stringValue = "All global shortcuts are paused while recording." } else if shortcut.isRegistered { statusLabel.stringValue = "Global shortcut is active." } else if shortcut.shortcut != nil { @@ -257,7 +268,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { @objc private func beginRecording() { guard !recorder.isRecording else { return } errorLabel.stringValue = "" - shortcut.suspend() + shortcuts.suspend() recorder.isRecording = true refresh() if window?.makeFirstResponder(recorder) != true { @@ -274,7 +285,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { 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() + restoreSavedShortcuts() refresh() return } @@ -286,11 +297,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { recorder.isRecording = false errorLabel.stringValue = "" do { - try shortcut.setShortcut(candidate) + try shortcuts.setShortcut(candidate, at: destinationSelector.indexOfSelectedItem) } catch { errorLabel.stringValue = error.localizedDescription - if wasRecording { restoreSavedShortcut() } } + if wasRecording { restoreSavedShortcuts() } refresh() } @@ -298,19 +309,23 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { guard recorder.isRecording else { return } recorder.isRecording = false errorLabel.stringValue = "" - restoreSavedShortcut() + restoreSavedShortcuts() refresh() } - private func restoreSavedShortcut() { - do { - try shortcut.setShortcut(shortcut.shortcut) - } catch { - let message = "Could not reactivate the saved shortcut: \(error.localizedDescription)" + private func restoreSavedShortcuts() { + let errors = shortcuts.start() + if !errors.isEmpty { + let message = errors.joined(separator: "\n") errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)" } } + @objc private func changeDestination() { + cancelRecording() + refresh() + } + @objc private func useCommandSpace() { apply(.commandSpace) } diff --git a/Sources/ConjureKit/GlobalShortcut.swift b/Sources/ConjureKit/GlobalShortcut.swift index ce7ca09..7a4912e 100644 --- a/Sources/ConjureKit/GlobalShortcut.swift +++ b/Sources/ConjureKit/GlobalShortcut.swift @@ -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." diff --git a/Sources/ConjureKit/GlobalShortcuts.swift b/Sources/ConjureKit/GlobalShortcuts.swift new file mode 100644 index 0000000..da75f85 --- /dev/null +++ b/Sources/ConjureKit/GlobalShortcuts.swift @@ -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 + } +} diff --git a/Sources/ConjureKit/KeyboardShortcut.swift b/Sources/ConjureKit/KeyboardShortcut.swift index 9d40ff6..b27cfc2 100644 --- a/Sources/ConjureKit/KeyboardShortcut.swift +++ b/Sources/ConjureKit/KeyboardShortcut.swift @@ -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" diff --git a/TODO.md b/TODO.md index 14ba937..ea67cb7 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,4 @@ - [x] add an emoji picker inspired raycast's emoji picker +- [ ] make the star/favorite symbol a bit smaller when prefixed launcher list entries (e.g. " Application", or " Command") - [ ] add a notes app inspired by raycast notes - [ ] improve on the conjure translation command \ No newline at end of file diff --git a/Tests/ConjureKitTests/GlobalShortcutsTests.swift b/Tests/ConjureKitTests/GlobalShortcutsTests.swift new file mode 100644 index 0000000..1805848 --- /dev/null +++ b/Tests/ConjureKitTests/GlobalShortcutsTests.swift @@ -0,0 +1,121 @@ +import AppKit +@testable import ConjureKit +import Testing + + +@Test @MainActor +func globalShortcutKeepsLegacyStorageAndScopesCustomStorage() throws { + let suite = "GlobalShortcutsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let commandShiftSpace = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift])) + defaults.set(KeyboardShortcut.commandSpace.storedValue, forKey: "globalActivationShortcut") + defaults.set(commandShiftSpace.storedValue, forKey: "customShortcut") + + let legacy = GlobalShortcut(defaults: defaults) {} + let custom = GlobalShortcut(defaults: defaults, storageKey: "customShortcut") {} + #expect(legacy.shortcut == .commandSpace) + #expect(custom.shortcut == commandShiftSpace) + try custom.setShortcut(nil) + #expect(defaults.object(forKey: "customShortcut") == nil) + #expect(KeyboardShortcut(storedValue: defaults.object(forKey: "globalActivationShortcut")) == .commandSpace) + #expect(GlobalShortcut(defaults: defaults, action: {}).shortcut == .commandSpace) +} + +@Test @MainActor +func duplicateGlobalBindingIsRejectedWhileSuspendedWithoutChangingSavedBinding() throws { + let suite = "GlobalShortcutsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let candidate = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift])) + let previous = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.control, .option])) + let emojiIndex = try #require(GlobalShortcuts.destinations.firstIndex(of: .command(.emoji))) + defaults.set(candidate.storedValue, forKey: "globalActivationShortcut") + defaults.set(previous.storedValue, forKey: "globalCommandShortcut.emoji") + let shortcuts = GlobalShortcuts(defaults: defaults) { _ in } + shortcuts.suspend() + + do { + try shortcuts.setShortcut(candidate, at: emojiIndex) + Issue.record("A duplicate shortcut was accepted") + } catch { + #expect(error.localizedDescription.contains("Applications")) + #expect(error.localizedDescription.contains("already assigned")) + } + let commandReturn = try #require(KeyboardShortcut(keyCode: 36, modifiers: .command)) + do { + try shortcuts.setShortcut(commandReturn, at: emojiIndex) + Issue.record("A Conjure action shortcut was accepted") + } catch { + #expect(error.localizedDescription.contains("already used by Conjure")) + #expect(error.localizedDescription.contains("confirm uninstall")) + } + #expect(shortcuts.bindings[emojiIndex].shortcut == previous) + #expect(KeyboardShortcut(storedValue: defaults.object(forKey: "globalCommandShortcut.emoji")) == previous) + #expect(shortcuts.bindings.allSatisfy { !$0.isRegistered }) + try shortcuts.validate(candidate, at: 0) + + try shortcuts.setShortcut(nil, at: emojiIndex) + let restored = GlobalShortcuts(defaults: defaults) { _ in } + #expect(restored.bindings[0].shortcut == candidate) + #expect(restored.bindings[emojiIndex].shortcut == nil) +} + +@Test @MainActor +func localShortcutConflictsMatchMenusActionsAndPanelNavigation() throws { + let commandKey = try #require(KeyboardShortcut(keyCode: 0, modifiers: .command)) + for key in [",", "h", "q", "w", "x", "c", "v", "a", "m", "k", "Return", "1", "9"] { + #expect(GlobalShortcuts.localAction(for: commandKey, keyName: key) != nil) + } + #expect(GlobalShortcuts.localAction(for: commandKey, keyName: "0") == nil) + let hideOthers = try #require(KeyboardShortcut(keyCode: 0, modifiers: [.command, .option])) + #expect(GlobalShortcuts.localAction(for: hideOthers, keyName: "H") == "Hide Others") + let controlKey = try #require(KeyboardShortcut(keyCode: 0, modifiers: .control)) + for key in ["h", "j", "k", "l"] { + #expect(GlobalShortcuts.localAction(for: controlKey, keyName: key) != nil) + } + let commandShiftSpace = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift])) + #expect(GlobalShortcuts.localAction(for: commandShiftSpace, keyName: "Space") == nil) + let commandShiftK = try #require(KeyboardShortcut(keyCode: 40, modifiers: [.command, .shift])) + #expect(GlobalShortcuts.localAction(for: commandShiftK, keyName: "K") == nil) +} + +@Test @MainActor +func startupReportsEveryInvalidSavedBindingWithoutDiscardingThem() throws { + let suite = "GlobalShortcutsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let commandReturn = try #require(KeyboardShortcut(keyCode: 36, modifiers: .command)) + defaults.set("invalid", forKey: "globalActivationShortcut") + defaults.set(commandReturn.storedValue, forKey: "globalCommandShortcut.translate") + defaults.set(["keyCode": -1, "modifiers": 0], forKey: "globalCommandShortcut.emoji") + let shortcuts = GlobalShortcuts(defaults: defaults) { _ in } + + let errors = shortcuts.start() + #expect(errors.count == 3) + #expect(errors[0].hasPrefix("Applications:")) + #expect(errors[1].hasPrefix("Conjure Translate:")) + #expect(errors[1].contains("already used by Conjure")) + #expect(errors[2].hasPrefix("Conjure Emoji:")) + #expect(defaults.string(forKey: "globalActivationShortcut") == "invalid") + #expect(KeyboardShortcut(storedValue: defaults.object(forKey: "globalCommandShortcut.translate")) == commandReturn) + #expect(defaults.object(forKey: "globalCommandShortcut.emoji") != nil) + #expect(shortcuts.bindings.allSatisfy { !$0.isRegistered }) +} + +@Test @MainActor +func startupRejectsEveryDuplicateEvenBeforeNativeRegistration() throws { + let suite = "GlobalShortcutsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let candidate = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift])) + defaults.set(candidate.storedValue, forKey: "globalActivationShortcut") + defaults.set(candidate.storedValue, forKey: "globalCommandShortcut.emoji") + let shortcuts = GlobalShortcuts(defaults: defaults) { _ in } + + let errors = shortcuts.start() + #expect(errors.count == 2) + #expect(errors[0].contains("Conjure Emoji")) + #expect(errors[1].contains("Applications")) + #expect(shortcuts.bindings.allSatisfy { !$0.isRegistered }) +} diff --git a/Tests/ConjureKitTests/ShortcutDestinationTests.swift b/Tests/ConjureKitTests/ShortcutDestinationTests.swift new file mode 100644 index 0000000..4269509 --- /dev/null +++ b/Tests/ConjureKitTests/ShortcutDestinationTests.swift @@ -0,0 +1,34 @@ +import AppKit +import ConjureKit +import Testing +@testable import Conjure + +@Test @MainActor func activationAlwaysTargetsRequestedViewAndClearsPreviousTool() throws { + _ = NSApplication.shared + let suite = "ConjureDestination.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let launcher = LauncherWindowController(history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) })) + let window = try #require(launcher.window) + defer { window.orderOut(nil) } + func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + let content = try #require(window.contentView) + let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first) + launcher.present(command: .emoji) + #expect(descendants(content).contains { $0 is NSCollectionView }) + search.stringValue = "happy" + launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search)) + window.orderOut(nil) + launcher.toggle() + #expect(!descendants(content).contains { $0 is NSCollectionView }) + #expect(search.stringValue.isEmpty) + #expect(search.accessibilityLabel() == "Search applications, calculate, or convert units and currencies") + launcher.toggle(command: .translate) + #expect(search.accessibilityLabel() == "English text to translate into Japanese") + launcher.toggle(command: .emoji) + #expect(search.accessibilityLabel() == "Search emoji") + #expect(search.stringValue.isEmpty) + launcher.present() + #expect(!descendants(content).contains { $0 is NSCollectionView }) + #expect(search.accessibilityLabel() == "Search applications, calculate, or convert units and currencies") +} diff --git a/Tests/ConjureKitTests/ToolFeedbackTests.swift b/Tests/ConjureKitTests/ToolFeedbackTests.swift index 5364156..e6abaa4 100644 --- a/Tests/ConjureKitTests/ToolFeedbackTests.swift +++ b/Tests/ConjureKitTests/ToolFeedbackTests.swift @@ -65,11 +65,11 @@ func shortcutModifierGuidanceDoesNotAppearAsAnError() throws { let suite = "Conjure.ToolFeedbackTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } - let controller = SettingsWindowController(shortcut: GlobalShortcut(defaults: defaults, action: {})) + let controller = SettingsWindowController(shortcuts: GlobalShortcuts(defaults: defaults, action: { _ in })) let window = try #require(controller.window) let views = descendants(try #require(window.contentView)) let recorder = try #require(views.compactMap { $0 as? NSButton }.first { - $0.accessibilityLabel() == "Record global shortcut" + $0.action == NSSelectorFromString("beginRecording") }) let error = try #require(views.compactMap { $0 as? NSTextField }.first { $0.accessibilityLabel() == "Shortcut feedback"