feat: configure a persistent global launcher shortcut

This commit is contained in:
2026-10-11 23:29:40 +02:00
parent 5842d86d9a
commit 7aed5c323c
7 changed files with 728 additions and 12 deletions
+24 -2
View File
@@ -1,4 +1,5 @@
import AppKit
import ConjureKit
@main
@MainActor
@@ -8,7 +9,7 @@ struct ConjureApp {
let delegate = ApplicationDelegate()
application.delegate = delegate
application.setActivationPolicy(.regular)
application.mainMenu = makeMainMenu()
application.mainMenu = makeMainMenu(delegate: delegate)
// NSApplication holds its delegate weakly.
withExtendedLifetime(delegate) {
@@ -16,7 +17,7 @@ struct ConjureApp {
}
}
private static func makeMainMenu() -> NSMenu {
private static func makeMainMenu(delegate: ApplicationDelegate) -> NSMenu {
let menu = NSMenu()
let applicationItem = NSMenuItem(title: "Conjure", action: nil, keyEquivalent: "")
let applicationMenu = NSMenu(title: "Conjure")
@@ -28,6 +29,12 @@ struct ConjureApp {
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
keyEquivalent: ""
)
let settings = applicationMenu.addItem(
withTitle: "Settings…",
action: #selector(ApplicationDelegate.showSettings(_:)),
keyEquivalent: ","
)
settings.target = delegate
applicationMenu.addItem(.separator())
applicationMenu.addItem(
withTitle: "Hide Conjure",
@@ -84,9 +91,16 @@ struct ConjureApp {
@MainActor
private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
private lazy var launcher = LauncherWindowController()
private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() }
private lazy var settings = SettingsWindowController(shortcut: shortcut)
func applicationDidFinishLaunching(_ notification: Notification) {
launcher.present()
do {
try shortcut.start()
} catch {
settings.present(error: error.localizedDescription)
}
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
@@ -94,6 +108,14 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
return false
}
@objc func showSettings(_ sender: Any?) {
settings.present()
}
func applicationWillTerminate(_ notification: Notification) {
shortcut.stop()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
+32 -5
View File
@@ -14,6 +14,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var locationLabels: [URL: String] = [:]
private var discoveryTask: Task<Void, Never>?
private var isLaunching = false
private var previousApplication: NSRunningApplication?
init(history: LaunchHistory = LaunchHistory()) {
self.history = history
@@ -36,6 +37,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
configureContent(in: window)
window.delegate = self
window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) }
window.dismiss = { [weak self] in self?.dismiss() }
}
required init?(coder: NSCoder) {
@@ -43,6 +45,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
func present() {
let frontmost = NSWorkspace.shared.frontmostApplication
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
previousApplication = frontmost
}
searchField.stringValue = ""
updateResults()
window?.deminiaturize(nil)
@@ -54,9 +60,29 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
refreshApplications()
}
func toggle() {
guard !isLaunching, window?.attachedSheet == nil else { return }
if window?.isVisible == true, window?.isKeyWindow == true {
dismiss()
} else {
present()
}
}
func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
let previous = previousApplication
previousApplication = nil
window?.orderOut(nil)
if restoringFocus, let previous, !previous.isTerminated {
NSApp.yieldActivation(to: previous)
previous.activate(from: .current, options: [])
}
}
func windowDidResignKey(_ notification: Notification) {
guard !isLaunching, window?.attachedSheet == nil else { return }
window?.orderOut(nil)
dismiss(restoringFocus: false)
}
private func configureContent(in window: NSWindow) {
@@ -120,7 +146,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
tableView.target = self
tableView.doubleAction = #selector(openSelection)
tableView.openSelection = { [weak self] in self?.openSelection() }
tableView.dismiss = { [weak self] in self?.window?.orderOut(nil) }
tableView.dismiss = { [weak self] in self?.dismiss() }
tableView.editSearch = { [weak self] event in
guard let self, let window = self.window else { return }
window.makeFirstResponder(self.searchField)
@@ -295,7 +321,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
case #selector(NSResponder.insertNewline(_:)):
openSelection()
case #selector(NSResponder.cancelOperation(_:)):
window?.orderOut(nil)
dismiss()
default:
return false
}
@@ -397,7 +423,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
do {
try await AppLauncher.launch(application.url)
history.recordLaunch(of: application)
window?.orderOut(nil)
dismiss(restoringFocus: false)
} catch {
let alert = NSAlert(error: error)
alert.messageText = "Couldn’t open \(application.name)"
@@ -445,6 +471,7 @@ private final class LauncherPanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
var navigateSelection: ((Int) -> Void)?
var dismiss: (() -> Void)?
override func sendEvent(_ event: NSEvent) {
if event.type == .keyDown, attachedSheet == nil,
@@ -464,7 +491,7 @@ private final class LauncherPanel: NSPanel {
}
override func performClose(_ sender: Any?) {
close()
dismiss?()
}
}
@@ -0,0 +1,263 @@
import AppKit
import ConjureKit
@MainActor
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private let shortcut: GlobalShortcut
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: "")
private let errorLabel = NSTextField(wrappingLabelWithString: "")
init(shortcut: GlobalShortcut) {
self.shortcut = shortcut
let window = ShortcutSettingsWindow(
contentRect: NSRect(x: 0, y: 0, width: 580, height: 520),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
window.title = "Conjure Settings"
window.isReleasedWhenClosed = false
window.center()
super.init(window: window)
window.delegate = self
window.recorder = recorder
configureContent(in: window)
refresh()
}
required init?(coder: NSCoder) {
fatalError("Conjure creates its settings window programmatically")
}
func present(error: String? = nil) {
cancelRecording()
if let error {
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? error : "\(error)\n\(errorLabel.stringValue)"
}
refresh()
window?.deminiaturize(nil)
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
}
func windowDidResignKey(_ notification: Notification) {
cancelRecording()
}
func windowWillClose(_ notification: Notification) {
cancelRecording()
}
private func configureContent(in window: NSWindow) {
let heading = NSTextField(labelWithString: "Global Shortcut")
heading.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
recorder.bezelStyle = .rounded
recorder.target = self
recorder.action = #selector(beginRecording)
recorder.setAccessibilityLabel("Record global shortcut")
recorder.capture = { [weak self] event in self?.capture(event) }
recorder.cancel = { [weak self] in self?.cancelRecording() }
recorder.modifiersChanged = { [weak self] in
self?.errorLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels."
}
let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, action: #selector(useCommandSpace))
commandSpaceButton.bezelStyle = .rounded
commandSpaceButton.setAccessibilityLabel("Use Command Space as the global shortcut")
clearButton.bezelStyle = .rounded
clearButton.target = self
clearButton.action = #selector(clearShortcut)
clearButton.setAccessibilityLabel("Clear global shortcut")
let controls = NSStackView(views: [recorder, commandSpaceButton, clearButton])
controls.orientation = .horizontal
controls.alignment = .centerY
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."
)
instructions.textColor = .secondaryLabelColor
statusLabel.textColor = .secondaryLabelColor
statusLabel.setAccessibilityLabel("Global shortcut registration status")
errorLabel.textColor = .systemRed
errorLabel.setAccessibilityLabel("Shortcut feedback")
let spotlight = NSTextField(wrappingLabelWithString:
"To use ⌘Space, first free it in System Settings → Keyboard → Keyboard Shortcuts → Spotlight: turn off or change “Show Spotlight search.” Check Input Sources for other reserved shortcuts. Conjure never changes these settings."
)
spotlight.textColor = .secondaryLabelColor
let keyboardSettings = NSButton(
title: "Open Keyboard Settings…", target: self, action: #selector(openKeyboardSettings)
)
keyboardSettings.bezelStyle = .rounded
let stack = NSStackView(views: [
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings
])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
let content = NSView()
window.contentView = content
content.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24),
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24),
recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190)
])
for label in [instructions, statusLabel, errorLabel, spotlight] {
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
}
private func refresh() {
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."
} else if shortcut.isRegistered {
statusLabel.stringValue = "Global shortcut is active."
} else if shortcut.shortcut != nil {
statusLabel.stringValue = "This shortcut is saved but is not active."
} else {
statusLabel.stringValue = "No global shortcut is configured."
}
}
@objc private func beginRecording() {
guard !recorder.isRecording else { return }
errorLabel.stringValue = ""
shortcut.suspend()
recorder.isRecording = true
refresh()
if window?.makeFirstResponder(recorder) != true {
cancelRecording()
}
}
private func capture(_ event: NSEvent) {
guard !event.isARepeat else { return }
if event.keyCode == 53, event.modifierFlags.intersection([.command, .option, .control]).isEmpty {
cancelRecording()
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."
restoreSavedShortcut()
refresh()
return
}
apply(candidate)
}
private func apply(_ candidate: KeyboardShortcut?) {
let wasRecording = recorder.isRecording
recorder.isRecording = false
errorLabel.stringValue = ""
do {
try shortcut.setShortcut(candidate)
} catch {
errorLabel.stringValue = error.localizedDescription
if wasRecording { restoreSavedShortcut() }
}
refresh()
}
private func cancelRecording() {
guard recorder.isRecording else { return }
recorder.isRecording = false
errorLabel.stringValue = ""
restoreSavedShortcut()
refresh()
}
private func restoreSavedShortcut() {
do {
try shortcut.setShortcut(shortcut.shortcut)
} catch {
let message = "Could not reactivate the saved shortcut: \(error.localizedDescription)"
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
}
}
@objc private func useCommandSpace() {
apply(.commandSpace)
}
@objc private func clearShortcut() {
apply(nil)
}
@objc private func openKeyboardSettings() {
cancelRecording()
let url = URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")!
if !NSWorkspace.shared.open(url) {
errorLabel.stringValue = "Could not open Keyboard settings. Open System Settings → Keyboard manually."
}
}
}
@MainActor
private final class ShortcutSettingsWindow: NSWindow {
weak var recorder: ShortcutRecorderButton?
override func performKeyEquivalent(with event: NSEvent) -> Bool {
if let recorder, recorder.isRecording, firstResponder === recorder {
return recorder.performKeyEquivalent(with: event)
}
return super.performKeyEquivalent(with: event)
}
}
@MainActor
private final class ShortcutRecorderButton: NSButton {
var isRecording = false
var capture: ((NSEvent) -> Void)?
var cancel: (() -> Void)?
var modifiersChanged: (() -> Void)?
override var acceptsFirstResponder: Bool { true }
override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard isRecording, window?.firstResponder === self else {
return super.performKeyEquivalent(with: event)
}
keyDown(with: event)
return true
}
override func keyDown(with event: NSEvent) {
guard isRecording else {
super.keyDown(with: event)
return
}
let activationModifiers: NSEvent.ModifierFlags = [.command, .option, .control]
if event.keyCode == 48, event.modifierFlags.intersection(activationModifiers).isEmpty {
if event.modifierFlags.contains(.shift) {
window?.selectPreviousKeyView(nil)
} else {
window?.selectNextKeyView(nil)
}
return
}
capture?(event)
}
override func flagsChanged(with event: NSEvent) {
if isRecording { modifiersChanged?() } else { super.flagsChanged(with: event) }
}
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
if resigned, isRecording { cancel?() }
return resigned
}
}