refactor: centralize shortcut recording lifetime
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
- [ ] improve on the conjure translation command
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user