diff --git a/README.md b/README.md index f8064e4..f21d188 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ In Settings, choose a **Global shortcut destination**: **Applications**, **Conju 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. +Cancelling recording, changing destination, or leaving the recorder restores saved shortcuts. If a replacement cannot be registered, the previous saved binding is retained; any failure to restore a shortcut is reported in Settings. + 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. On macOS 27, [Siri AI uses Command-Shift-Space to select a window for screen capture](https://support.apple.com/en-is/guide/mac-help/iphmk4p88mkuky/mac). That system reservation can conflict even though it is not listed under Spotlight or Input Sources. Use another binding unless you have freed the Siri shortcut. diff --git a/Sources/Conjure/SettingsWindowController.swift b/Sources/Conjure/SettingsWindowController.swift index cc50302..1fe5d42 100644 --- a/Sources/Conjure/SettingsWindowController.swift +++ b/Sources/Conjure/SettingsWindowController.swift @@ -249,6 +249,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private func refresh() { refreshLogin() + recorder.isRecording = shortcuts.isRecording commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0 recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut") let name = shortcut.shortcut?.displayName @@ -266,10 +267,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } @objc private func beginRecording() { - guard !recorder.isRecording else { return } + guard !shortcuts.isRecording else { return } errorLabel.stringValue = "" - shortcuts.suspend() - recorder.isRecording = true + shortcuts.beginRecording() refresh() if window?.makeFirstResponder(recorder) != true { cancelRecording() @@ -283,9 +283,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { 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." - restoreSavedShortcuts() + appendShortcutErrors(shortcuts.cancelRecording()) refresh() return } @@ -293,28 +292,19 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } private func apply(_ candidate: KeyboardShortcut?) { - let wasRecording = recorder.isRecording - recorder.isRecording = false errorLabel.stringValue = "" - do { - try shortcuts.setShortcut(candidate, at: destinationSelector.indexOfSelectedItem) - } catch { - errorLabel.stringValue = error.localizedDescription - } - if wasRecording { restoreSavedShortcuts() } + appendShortcutErrors(shortcuts.finishRecording(candidate, at: destinationSelector.indexOfSelectedItem)) refresh() } private func cancelRecording() { - guard recorder.isRecording else { return } - recorder.isRecording = false + guard shortcuts.isRecording else { return } errorLabel.stringValue = "" - restoreSavedShortcuts() + appendShortcutErrors(shortcuts.cancelRecording()) refresh() } - private func restoreSavedShortcuts() { - let errors = shortcuts.start() + private func appendShortcutErrors(_ errors: [String]) { if !errors.isEmpty { let message = errors.joined(separator: "\n") errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)" diff --git a/Sources/ConjureKit/GlobalShortcut.swift b/Sources/ConjureKit/GlobalShortcut.swift index f1beb40..31d303a 100644 --- a/Sources/ConjureKit/GlobalShortcut.swift +++ b/Sources/ConjureKit/GlobalShortcut.swift @@ -10,6 +10,18 @@ public final class GlobalShortcut { 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, @@ -44,7 +56,7 @@ public final class GlobalShortcut { try activate(candidate) defaults.set(candidate.storedValue, forKey: storageKey) } else { - try native.unregister() + try unregister() isRegistered = false defaults.removeObject(forKey: storageKey) } @@ -53,7 +65,7 @@ public final class GlobalShortcut { public func suspend() { do { - try native.unregister() + try unregister() isRegistered = false } catch { NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription) @@ -61,7 +73,7 @@ public final class GlobalShortcut { } public func stop() { - native.dispose() + if let registration { registration.stop() } else { native.dispose() } isRegistered = false } @@ -69,9 +81,17 @@ public final class GlobalShortcut { action() } + private func unregister() throws { + if let registration { try registration.unregister() } else { try native.unregister() } + } + private func activate(_ candidate: KeyboardShortcut) throws { - try Self.rejectSystemConflict(candidate) - try native.register(candidate) + if let registration { + try registration.activate(candidate) + } else { + try Self.rejectSystemConflict(candidate) + try native.register(candidate) + } isRegistered = true } diff --git a/Sources/ConjureKit/GlobalShortcuts.swift b/Sources/ConjureKit/GlobalShortcuts.swift index da75f85..eb14f11 100644 --- a/Sources/ConjureKit/GlobalShortcuts.swift +++ b/Sources/ConjureKit/GlobalShortcuts.swift @@ -27,6 +27,13 @@ public final class GlobalShortcuts { 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, @@ -60,11 +67,23 @@ public final class GlobalShortcuts { return errors } - public func suspend() { + 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() } diff --git a/TODO.md b/TODO.md index f58f0ba..656df57 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,2 @@ -- [x] add an emoji picker inspired raycast's emoji picker -- [x] make the star/favorite symbol a bit smaller when prefixed launcher list entries (e.g. " Application", or " Command") -- [x] currently the top fade (from the top section/overlay, typically some search input) overshadows the top of main content of the window. - - spacing should be added such that the fade doesn't overlay the content. - - of course, if the user scrolls some scrollable content, the content would (and should) naturally be overshadowed. - [ ] 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 index 1805848..05b319d 100644 --- a/Tests/ConjureKitTests/GlobalShortcutsTests.swift +++ b/Tests/ConjureKitTests/GlobalShortcutsTests.swift @@ -33,7 +33,7 @@ func duplicateGlobalBindingIsRejectedWhileSuspendedWithoutChangingSavedBinding() defaults.set(candidate.storedValue, forKey: "globalActivationShortcut") defaults.set(previous.storedValue, forKey: "globalCommandShortcut.emoji") let shortcuts = GlobalShortcuts(defaults: defaults) { _ in } - shortcuts.suspend() + shortcuts.beginRecording() do { try shortcuts.setShortcut(candidate, at: emojiIndex) diff --git a/Tests/ConjureKitTests/ShortcutRecordingTests.swift b/Tests/ConjureKitTests/ShortcutRecordingTests.swift new file mode 100644 index 0000000..eca75f8 --- /dev/null +++ b/Tests/ConjureKitTests/ShortcutRecordingTests.swift @@ -0,0 +1,143 @@ +import AppKit +import Foundation +import Testing +@testable import ConjureKit +@testable import Conjure + +@MainActor +private final class RecordingRegistration { + var active: KeyboardShortcut? + var rejected: KeyboardShortcut? + var unavailable = false + + var adapter: GlobalShortcut.Registration { + .init(activate: { [self] candidate in + if unavailable || candidate == rejected { throw RecordingFailure() } + active = candidate + }, unregister: { [self] in active = nil }, stop: { [self] in active = nil }) + } +} + +private struct RecordingFailure: LocalizedError { + var errorDescription: String? { "Registration denied" } +} + +@Test @MainActor +func recordingOwnsCompletionCancellationAndFailureRestoration() throws { + let suite = "ShortcutRecording.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let old = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.control, .option])) + let candidate = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift])) + let other = try #require(KeyboardShortcut(keyCode: 0, modifiers: [.control, .option])) + let states = GlobalShortcuts.destinations.map { _ in RecordingRegistration() } + defaults.set(old.storedValue, forKey: GlobalShortcuts.destinations[0].storageKey) + defaults.set(other.storedValue, forKey: GlobalShortcuts.destinations[1].storageKey) + let bindings = GlobalShortcuts.destinations.enumerated().map { index, destination in + GlobalShortcut(defaults: defaults, storageKey: destination.storageKey, registration: states[index].adapter) + } + let shortcuts = GlobalShortcuts(defaults: defaults, bindings: bindings) + defer { shortcuts.stop() } + #expect(shortcuts.start().isEmpty) + #expect(states[0].active == old) + #expect(states[1].active == other) + + shortcuts.beginRecording() + shortcuts.beginRecording() // Beginning twice must not change the saved bindings. + #expect(shortcuts.isRecording) + #expect(states.allSatisfy { $0.active == nil }) + #expect(shortcuts.cancelRecording().isEmpty) + #expect(!shortcuts.isRecording) + #expect(states[0].active == old) + #expect(states[1].active == other) + #expect(shortcuts.cancelRecording().isEmpty) + + shortcuts.beginRecording() + states[0].rejected = candidate + let failure = shortcuts.finishRecording(candidate, at: 0) + #expect(failure.contains { $0.contains("Registration denied") }) + #expect(!shortcuts.isRecording) + #expect(bindings[0].shortcut == old) + #expect(KeyboardShortcut(storedValue: defaults.object(forKey: GlobalShortcuts.destinations[0].storageKey)) == old) + #expect(states[0].active == old) + #expect(states[1].active == other) + + shortcuts.beginRecording() + states[0].rejected = nil + #expect(shortcuts.finishRecording(candidate, at: 0).isEmpty) + #expect(!shortcuts.isRecording) + #expect(states[0].active == candidate) + #expect(states[1].active == other) + #expect(KeyboardShortcut(storedValue: defaults.object(forKey: GlobalShortcuts.destinations[0].storageKey)) == candidate) + + shortcuts.beginRecording() + #expect(!shortcuts.finishRecording(other, at: 0).isEmpty) // Duplicate: still restore everything. + #expect(states[0].active == candidate) + #expect(states[1].active == other) + #expect(!shortcuts.isRecording) + + shortcuts.beginRecording() + states[1].unavailable = true + let restorationFailure = shortcuts.cancelRecording() + #expect(restorationFailure.contains { $0.hasPrefix(GlobalShortcuts.destinations[1].title) }) + #expect(!shortcuts.isRecording) + #expect(states[0].active == candidate) + #expect(states[1].active == nil) + #expect(bindings[1].shortcut == other) + + #expect(shortcuts.finishRecording(nil, at: 0).isEmpty) // Clear outside recording must not resume other bindings. + #expect(states[0].active == nil) + #expect(states[1].active == nil) + #expect(defaults.object(forKey: GlobalShortcuts.destinations[0].storageKey) == nil) +} + +@Test(arguments: ["close", "focus", "destination", "escape", "invalid", "responder"]) +@MainActor +func recordingSettingsExitRestoresSavedShortcuts(_ exit: String) throws { + _ = NSApplication.shared + let suite = "RecordingExit.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let shortcut = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.control, .option])) + defaults.set(shortcut.storedValue, forKey: GlobalShortcuts.destinations[0].storageKey) + let states = GlobalShortcuts.destinations.map { _ in RecordingRegistration() } + let bindings = GlobalShortcuts.destinations.enumerated().map { index, destination in + GlobalShortcut(defaults: defaults, storageKey: destination.storageKey, registration: states[index].adapter) + } + let shortcuts = GlobalShortcuts(defaults: defaults, bindings: bindings) + defer { shortcuts.stop() } + #expect(shortcuts.start().isEmpty) + let settings = SettingsWindowController(shortcuts: shortcuts) + let window = try #require(settings.window) + defer { window.close() } + func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + let views = descendants(try #require(window.contentView)) + let recorder = try #require(views.compactMap { $0 as? NSButton }.first { + $0.action == NSSelectorFromString("beginRecording") + }) + recorder.performClick(nil) + #expect(shortcuts.isRecording) + #expect(states[0].active == nil) + switch exit { + case "close": + settings.windowWillClose(Notification(name: NSWindow.willCloseNotification, object: window)) + case "focus": + settings.windowDidResignKey(Notification(name: NSWindow.didResignKeyNotification, object: window)) + case "destination": + let destination = try #require(views.compactMap { $0 as? NSPopUpButton }.first) + destination.selectItem(at: 1) + _ = settings.perform(destination.action, with: destination) + case "responder": + _ = window.makeFirstResponder(nil) + default: + let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 1, windowNumber: window.windowNumber, context: nil, + characters: exit == "escape" ? "\u{1b}" : "a", + charactersIgnoringModifiers: exit == "escape" ? "\u{1b}" : "a", + isARepeat: false, keyCode: exit == "escape" ? 53 : 0)) + recorder.keyDown(with: event) + } + #expect(!shortcuts.isRecording) + #expect(states[0].active == shortcut) + #expect(bindings[0].isRegistered) +}