refactor: centralize shortcut recording lifetime

This commit is contained in:
Valdemar Lorenzen
2026-10-16 10:23:20 +02:00
parent 04e505b846
commit 6725be8ad5
7 changed files with 199 additions and 30 deletions
+2
View File
@@ -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. 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. 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. 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.
+8 -18
View File
@@ -249,6 +249,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private func refresh() { private func refresh() {
refreshLogin() refreshLogin()
recorder.isRecording = shortcuts.isRecording
commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0 commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0
recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut") recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut")
let name = shortcut.shortcut?.displayName let name = shortcut.shortcut?.displayName
@@ -266,10 +267,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
} }
@objc private func beginRecording() { @objc private func beginRecording() {
guard !recorder.isRecording else { return } guard !shortcuts.isRecording else { return }
errorLabel.stringValue = "" errorLabel.stringValue = ""
shortcuts.suspend() shortcuts.beginRecording()
recorder.isRecording = true
refresh() refresh()
if window?.makeFirstResponder(recorder) != true { if window?.makeFirstResponder(recorder) != true {
cancelRecording() cancelRecording()
@@ -283,9 +283,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
return return
} }
guard let candidate = KeyboardShortcut(keyCode: event.keyCode, modifiers: event.modifierFlags) else { 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." errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut."
restoreSavedShortcuts() appendShortcutErrors(shortcuts.cancelRecording())
refresh() refresh()
return return
} }
@@ -293,28 +292,19 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
} }
private func apply(_ candidate: KeyboardShortcut?) { private func apply(_ candidate: KeyboardShortcut?) {
let wasRecording = recorder.isRecording
recorder.isRecording = false
errorLabel.stringValue = "" errorLabel.stringValue = ""
do { appendShortcutErrors(shortcuts.finishRecording(candidate, at: destinationSelector.indexOfSelectedItem))
try shortcuts.setShortcut(candidate, at: destinationSelector.indexOfSelectedItem)
} catch {
errorLabel.stringValue = error.localizedDescription
}
if wasRecording { restoreSavedShortcuts() }
refresh() refresh()
} }
private func cancelRecording() { private func cancelRecording() {
guard recorder.isRecording else { return } guard shortcuts.isRecording else { return }
recorder.isRecording = false
errorLabel.stringValue = "" errorLabel.stringValue = ""
restoreSavedShortcuts() appendShortcutErrors(shortcuts.cancelRecording())
refresh() refresh()
} }
private func restoreSavedShortcuts() { private func appendShortcutErrors(_ errors: [String]) {
let errors = shortcuts.start()
if !errors.isEmpty { if !errors.isEmpty {
let message = errors.joined(separator: "\n") let message = errors.joined(separator: "\n")
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)" errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
+25 -5
View File
@@ -10,6 +10,18 @@ public final class GlobalShortcut {
private let defaults: UserDefaults private let defaults: UserDefaults
private let action: @MainActor () -> Void private let action: @MainActor () -> Void
private let native = NativeHotKey() 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( public init(
defaults: UserDefaults = .standard, defaults: UserDefaults = .standard,
@@ -44,7 +56,7 @@ public final class GlobalShortcut {
try activate(candidate) try activate(candidate)
defaults.set(candidate.storedValue, forKey: storageKey) defaults.set(candidate.storedValue, forKey: storageKey)
} else { } else {
try native.unregister() try unregister()
isRegistered = false isRegistered = false
defaults.removeObject(forKey: storageKey) defaults.removeObject(forKey: storageKey)
} }
@@ -53,7 +65,7 @@ public final class GlobalShortcut {
public func suspend() { public func suspend() {
do { do {
try native.unregister() try unregister()
isRegistered = false isRegistered = false
} catch { } catch {
NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription) NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription)
@@ -61,7 +73,7 @@ public final class GlobalShortcut {
} }
public func stop() { public func stop() {
native.dispose() if let registration { registration.stop() } else { native.dispose() }
isRegistered = false isRegistered = false
} }
@@ -69,9 +81,17 @@ public final class GlobalShortcut {
action() action()
} }
private func unregister() throws {
if let registration { try registration.unregister() } else { try native.unregister() }
}
private func activate(_ candidate: KeyboardShortcut) throws { private func activate(_ candidate: KeyboardShortcut) throws {
try Self.rejectSystemConflict(candidate) if let registration {
try native.register(candidate) try registration.activate(candidate)
} else {
try Self.rejectSystemConflict(candidate)
try native.register(candidate)
}
isRegistered = true isRegistered = true
} }
+20 -1
View File
@@ -27,6 +27,13 @@ public final class GlobalShortcuts {
public let bindings: [GlobalShortcut] public let bindings: [GlobalShortcut]
private let defaults: UserDefaults private let defaults: UserDefaults
private var isSuspended = false 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( public init(
defaults: UserDefaults = .standard, defaults: UserDefaults = .standard,
@@ -60,11 +67,23 @@ public final class GlobalShortcuts {
return errors return errors
} }
public func suspend() { public func beginRecording() {
isSuspended = true isSuspended = true
bindings.forEach { $0.suspend() } 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() { public func stop() {
isSuspended = false isSuspended = false
bindings.forEach { $0.stop() } bindings.forEach { $0.stop() }
-5
View File
@@ -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. "<star> Application", or "<star> 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 - [ ] add a notes app inspired by raycast notes
- [ ] improve on the conjure translation command - [ ] improve on the conjure translation command
@@ -33,7 +33,7 @@ func duplicateGlobalBindingIsRejectedWhileSuspendedWithoutChangingSavedBinding()
defaults.set(candidate.storedValue, forKey: "globalActivationShortcut") defaults.set(candidate.storedValue, forKey: "globalActivationShortcut")
defaults.set(previous.storedValue, forKey: "globalCommandShortcut.emoji") defaults.set(previous.storedValue, forKey: "globalCommandShortcut.emoji")
let shortcuts = GlobalShortcuts(defaults: defaults) { _ in } let shortcuts = GlobalShortcuts(defaults: defaults) { _ in }
shortcuts.suspend() shortcuts.beginRecording()
do { do {
try shortcuts.setShortcut(candidate, at: emojiIndex) try shortcuts.setShortcut(candidate, at: emojiIndex)
@@ -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)
}