fix: run action-only commands without presenting launcher

This commit is contained in:
2026-10-18 18:56:56 +02:00
parent a68694e66b
commit 0074c88e5d
5 changed files with 91 additions and 19 deletions
+6 -4
View File
@@ -71,9 +71,9 @@ The header’s trailing edge is constrained to the footer’s trailing edge. The
### Global shortcuts and launch at login ### Global shortcuts and launch at login
In Settings, choose a **Global shortcut destination**: **Applications**, **Conjure Emoji**, **Conjure Notes**, **Conjure Translate**, or **Conjure Toggle Light/Dark Theme**. 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. In Settings, choose a **Global shortcut destination**: **Applications**, **Conjure Emoji**, **Conjure Notes**, **Conjure Translate**, or **Toggle System Theme**. 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.
The Applications shortcut always targets the application launcher, not the last-used tool. Command shortcuts open their selected tool directly; the theme shortcut switches the system appearance immediately and dismisses Conjure on success. Pressing a tool 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. View commands open their selected tool directly. Action-only shortcuts, such as Toggle System Theme, execute in the background without opening the launcher, taking focus, clearing a search, or closing an existing tool. Pressing a tool shortcut while its destination is already frontmost dismisses Conjure; switching view 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. 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.
@@ -126,9 +126,11 @@ The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.co
## System theme ## System theme
Search for **Conjure Toggle Light/Dark Theme** (try `theme`, `light`, or `dark`) and press Return to switch macOS from light to dark appearance or back. It runs immediately, without opening a tool view, and dismisses Conjure on success. You can favorite it or assign a global shortcut in Settings. Search for **Toggle System Theme** (try `theme` or `system`) and press Return to switch macOS from light to dark appearance or back. Selecting it in the launcher dismisses Conjure on success. Its global shortcut runs in the background without presenting a window or changing the current launcher view. You can favorite it or assign a shortcut in Settings; existing theme keybindings and favorites survive the rename.
The first use may ask permission to control **System Events**. Allow it under **System Settings → Privacy & Security → Automation → Conjure**. If permission is denied or macOS reports an error, Conjure stays open and shows the failure. Conjure's own launcher keeps its existing dark styling. The first use may ask permission to control **System Events**. Allow it under **System Settings → Privacy & Security → Automation → Conjure**. If permission is denied or macOS reports an error, Conjure shows the failure in the visible launcher or a standalone alert when the launcher is hidden. Conjure's own launcher keeps its existing dark styling.
Commands explicitly declare `Application.Command.presentsView`. The shared dispatcher routes action-only commands before any launcher presentation; new commands must choose their behavior in the exhaustive metadata switch. Command IDs remain independent of display names so renaming does not reset saved shortcuts, favorites, or usage.
## Notes ## Notes
+31 -13
View File
@@ -131,6 +131,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func present(command: Application.Command? = nil) { func present(command: Application.Command? = nil) {
guard !isLaunching, window?.attachedSheet == nil else { return } guard !isLaunching, window?.attachedSheet == nil else { return }
if let command, !command.presentsView {
guard launcherFrameBeforeReview == nil else { return }
executeCommand(command)
return
}
if launcherFrameBeforeReview != nil { if launcherFrameBeforeReview != nil {
pendingPresentation = { [weak self] in self?.present(command: command) } pendingPresentation = { [weak self] in self?.present(command: command) }
uninstallReview?.goBack() uninstallReview?.goBack()
@@ -147,7 +152,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
restorePosition() restorePosition()
updateResults() updateResults()
if let command { if let command {
showCommand(command) executeCommand(command)
} }
window?.deminiaturize(nil) window?.deminiaturize(nil)
showWindow(nil) showWindow(nil)
@@ -738,7 +743,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func openApplication(_ application: Application) { private func openApplication(_ application: Application) {
guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return } guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if let command = application.command { if let command = application.command {
showCommand(command) executeCommand(command, dismissOnSuccess: true)
return return
} }
(window as? LauncherPanel)?.resetNumberHints() (window as? LauncherPanel)?.resetNumberHints()
@@ -804,27 +809,41 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return true return true
} }
private func toggleTheme() { private func runAction(_ command: Application.Command, dismissOnSuccess: Bool) {
actions.close(animated: false) let action: @MainActor () async throws -> Void
switch command {
case .toggleTheme: action = toggleSystemTheme
case .translate, .emoji, .notes:
preconditionFailure("View commands must be presented, not run as actions")
}
if dismissOnSuccess { actions.close(animated: false) }
isLaunching = true isLaunching = true
updateOpenButton() updateOpenButton()
Task { Task {
do { do {
try await toggleSystemTheme() try await action()
history.recordLaunch(of: Application(command: .toggleTheme)) history.recordLaunch(of: Application(command: command))
isLaunching = false isLaunching = false
dismiss() if dismissOnSuccess { dismiss() }
} catch { } catch {
isLaunching = false
let alert = NSAlert(error: error) let alert = NSAlert(error: error)
alert.messageText = "Couldn’t toggle system theme" alert.messageText = "Couldn’t run \(Application(command: command).name)"
if let window { await alert.beginSheetModal(for: window) } if let window, window.isVisible {
await alert.beginSheetModal(for: window)
} else {
alert.runModal()
} }
}
isLaunching = false
updateOpenButton() updateOpenButton()
} }
} }
private func showCommand(_ command: Application.Command) { private func executeCommand(_ command: Application.Command, dismissOnSuccess: Bool = false) {
guard command.presentsView else {
runAction(command, dismissOnSuccess: dismissOnSuccess)
return
}
guard !isCommandOpen, let content = window?.contentView else { return } guard !isCommandOpen, let content = window?.contentView else { return }
let controller: NSViewController let controller: NSViewController
let placeholder: String let placeholder: String
@@ -865,8 +884,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
placeholder = "Conjure Notes" placeholder = "Conjure Notes"
accessibilityLabel = "Conjure Notes" accessibilityLabel = "Conjure Notes"
case .toggleTheme: case .toggleTheme:
toggleTheme() preconditionFailure("Action commands must run without presenting a view")
return
} }
activeCommand = command activeCommand = command
history.recordLaunch(of: Application(command: command)) history.recordLaunch(of: Application(command: command))
+8 -1
View File
@@ -6,6 +6,13 @@ public struct Application: Identifiable, Sendable {
case emoji case emoji
case notes case notes
case toggleTheme case toggleTheme
public var presentsView: Bool {
switch self {
case .translate, .emoji, .notes: true
case .toggleTheme: false
}
}
} }
public static let commands = Command.allCases.map { Application(command: $0) } public static let commands = Command.allCases.map { Application(command: $0) }
public let command: Command? public let command: Command?
@@ -23,7 +30,7 @@ public struct Application: Identifiable, Sendable {
case .translate: "Conjure Translate" case .translate: "Conjure Translate"
case .emoji: "Conjure Emoji" case .emoji: "Conjure Emoji"
case .notes: "Conjure Notes" case .notes: "Conjure Notes"
case .toggleTheme: "Conjure Toggle Light/Dark Theme" case .toggleTheme: "Toggle System Theme"
} }
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command) self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command)
} }
@@ -33,7 +33,7 @@ import Testing
#expect(search.accessibilityLabel() == "Search applications, calculate, or convert units and currencies") #expect(search.accessibilityLabel() == "Search applications, calculate, or convert units and currencies")
} }
@Test(arguments: [Application.Command.translate, .emoji, .notes], [true, false]) @Test(arguments: Application.Command.allCases.filter(\.presentsView), [true, false])
@MainActor func commandsAreFullyVisibleImmediately(_ command: Application.Command, _ fromShortcut: Bool) throws { @MainActor func commandsAreFullyVisibleImmediately(_ command: Application.Command, _ fromShortcut: Bool) throws {
_ = NSApplication.shared _ = NSApplication.shared
let suite = "ConjurePresentation.\(UUID().uuidString)" let suite = "ConjurePresentation.\(UUID().uuidString)"
@@ -59,6 +59,51 @@ func themeToggleDismissesOnlyOnSuccessAndPreventsOverlappingRequests(_ succeeds:
} }
} }
@MainActor private final class ThemeWindowEvents {
var becameKey = false
}
@Test(.serialized, arguments: [true, false], [true, false]) @MainActor
func actionActivationPreservesWindowVisibilityAndSearch(_ directPresentation: Bool, _ initiallyVisible: Bool) async throws {
_ = NSApplication.shared
let suite = "ConjureBackgroundTheme.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let history = LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) })
let theme = Application(command: .toggleTheme)
let competitor = Application(url: URL(fileURLWithPath: "/Applications/Zebra.app"), name: "Zebra")
let entries = [theme, competitor]
history.recordLaunch(of: competitor)
var pending: CheckedContinuation<Void, Never>?
let launcher = LauncherWindowController(history: history, defaults: defaults, toggleSystemTheme: {
await withCheckedContinuation { pending = $0 }
})
let window = try #require(launcher.window)
defer { window.orderOut(nil) }
if initiallyVisible { window.orderFront(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)
search.stringValue = "keep this search"
let events = ThemeWindowEvents()
let observer = NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main
) { _ in MainActor.assumeIsolated { events.becameKey = true } }
defer { NotificationCenter.default.removeObserver(observer) }
if directPresentation { launcher.present(command: .toggleTheme) }
else { launcher.toggle(command: .toggleTheme) }
#expect(window.isVisible == initiallyVisible)
try await waitForTheme("Background action started") { pending != nil }
#expect(window.isVisible == initiallyVisible)
#expect(search.stringValue == "keep this search")
#expect(history.suggestions(in: entries).first?.id == competitor.id)
pending?.resume()
try await waitForTheme("Background action completed") { history.suggestions(in: entries).first?.id == theme.id }
#expect(window.isVisible == initiallyVisible)
#expect(search.stringValue == "keep this search")
#expect(!events.becameKey)
}
@MainActor private func waitForTheme(_ state: Comment, _ condition: () -> Bool) async throws { @MainActor private func waitForTheme(_ state: Comment, _ condition: () -> Bool) async throws {
// Scheduling deadline only; launch ranking uses the fixed clock above. // Scheduling deadline only; launch ranking uses the fixed clock above.
for _ in 0..<1_000 { for _ in 0..<1_000 {