feat: add native macOS application launcher

This commit is contained in:
2026-10-11 18:16:01 +02:00
commit 0d033667ff
10 changed files with 780 additions and 0 deletions
@@ -0,0 +1,280 @@
import AppKit
import ConjureKit
@MainActor
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private let searchField = NSSearchField()
private let tableView = ApplicationTableView()
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
private let openButton = NSButton(title: "Open Application", target: nil, action: nil)
private var applications: [Application] = []
private var results: [Application] = []
private var discoveryTask: Task<Void, Never>?
private var isLaunching = false
init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Conjure"
window.contentMinSize = NSSize(width: 420, height: 300)
window.isReleasedWhenClosed = false
window.center()
super.init(window: window)
configureContent(in: window)
window.delegate = self
}
required init?(coder: NSCoder) {
fatalError("Conjure creates its window programmatically")
}
func present() {
searchField.stringValue = ""
updateResults()
window?.deminiaturize(nil)
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
window?.makeFirstResponder(searchField)
refreshApplications()
}
func windowDidResignKey(_ notification: Notification) {
guard !isLaunching, window?.attachedSheet == nil else { return }
window?.orderOut(nil)
}
private func configureContent(in window: NSWindow) {
guard let content = window.contentView else { return }
searchField.placeholderString = "Search applications"
searchField.controlSize = .large
searchField.font = .systemFont(ofSize: NSFont.systemFontSize(for: .large))
searchField.sendsSearchStringImmediately = true
searchField.sendsWholeSearchString = false
searchField.delegate = self
searchField.setAccessibilityLabel("Search applications")
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application"))
column.resizingMask = .autoresizingMask
tableView.addTableColumn(column)
tableView.headerView = nil
tableView.style = .inset
tableView.rowHeight = 44
tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle
tableView.allowsMultipleSelection = false
tableView.allowsEmptySelection = false
tableView.dataSource = self
tableView.delegate = self
tableView.target = self
tableView.doubleAction = #selector(openSelection)
tableView.openSelection = { [weak self] in self?.openSelection() }
tableView.dismiss = { [weak self] in self?.window?.orderOut(nil) }
tableView.editSearch = { [weak self] event in
guard let self, let window = self.window else { return }
window.makeFirstResponder(self.searchField)
self.searchField.currentEditor()?.keyDown(with: event)
}
tableView.setAccessibilityLabel("Applications")
let scrollView = NSScrollView()
scrollView.documentView = tableView
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
scrollView.borderType = .noBorder
statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
statusLabel.textColor = .secondaryLabelColor
statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
emptyLabel.textColor = .secondaryLabelColor
emptyLabel.alignment = .center
openButton.bezelStyle = .rounded
openButton.keyEquivalent = "\r"
openButton.target = self
openButton.action = #selector(openSelection)
openButton.isEnabled = false
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
let footer = NSStackView(views: [statusLabel, NSView(), openButton])
footer.orientation = .horizontal
footer.spacing = 12
for view in [searchField, scrollView, emptyLabel, footer] {
view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(view)
}
NSLayoutConstraint.activate([
searchField.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
searchField.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
searchField.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
scrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 12),
scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -12),
emptyLabel.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
emptyLabel.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 16),
emptyLabel.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -16),
footer.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
footer.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12),
])
window.initialFirstResponder = searchField
}
private func refreshApplications() {
guard discoveryTask == nil else { return }
statusLabel.stringValue = "Finding applications…"
discoveryTask = Task { [weak self] in
let applications = await Task.detached(priority: .userInitiated) {
ApplicationCatalog.discover()
}.value
guard let self else { return }
self.applications = applications
self.discoveryTask = nil
self.updateResults()
}
}
private func updateResults() {
results = ApplicationSearch.results(for: searchField.stringValue, in: applications)
tableView.reloadData()
if !results.isEmpty {
tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
tableView.scrollRowToVisible(0)
}
emptyLabel.isHidden = !results.isEmpty
if discoveryTask != nil {
emptyLabel.stringValue = "Finding applications…"
statusLabel.stringValue = "Finding applications…"
} else {
emptyLabel.stringValue = applications.isEmpty ? "No applications found" : "No matching applications"
statusLabel.stringValue = results.count == 1 ? "1 application" : "\(results.count) applications"
}
updateOpenButton()
}
func controlTextDidChange(_ notification: Notification) {
updateResults()
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
switch commandSelector {
case #selector(NSResponder.moveDown(_:)):
moveSelection(by: 1)
case #selector(NSResponder.moveUp(_:)):
moveSelection(by: -1)
case #selector(NSResponder.insertNewline(_:)):
openSelection()
case #selector(NSResponder.cancelOperation(_:)):
window?.orderOut(nil)
default:
return false
}
return true
}
private func moveSelection(by offset: Int) {
guard !results.isEmpty else { return }
let row = min(max(tableView.selectedRow + offset, 0), results.count - 1)
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
tableView.scrollRowToVisible(row)
}
private func updateOpenButton() {
openButton.isEnabled = !isLaunching && results.indices.contains(tableView.selectedRow)
}
func tableViewSelectionDidChange(_ notification: Notification) {
updateOpenButton()
}
func numberOfRows(in tableView: NSTableView) -> Int {
results.count
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("applicationCell")
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView)
?? makeCell(identifier: identifier)
let application = results[row]
cell.textField?.stringValue = application.name
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
cell.toolTip = application.url.path
return cell
}
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView {
let cell = NSTableCellView()
cell.identifier = identifier
let icon = NSImageView()
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
let label = NSTextField(labelWithString: "")
label.lineBreakMode = .byTruncatingTail
cell.imageView = icon
cell.textField = label
for view in [icon, label] {
view.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(view)
}
NSLayoutConstraint.activate([
icon.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8),
icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
icon.widthAnchor.constraint(equalToConstant: 28),
icon.heightAnchor.constraint(equalToConstant: 28),
label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
label.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
label.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
])
return cell
}
@objc private func openSelection() {
guard !isLaunching, results.indices.contains(tableView.selectedRow) else { return }
let application = results[tableView.selectedRow]
isLaunching = true
updateOpenButton()
Task {
do {
try await AppLauncher.launch(application.url)
window?.orderOut(nil)
} catch {
let alert = NSAlert(error: error)
alert.messageText = "Couldn’t open \(application.name)"
if let window {
await alert.beginSheetModal(for: window)
}
}
isLaunching = false
updateOpenButton()
}
}
}
@MainActor
private final class ApplicationTableView: NSTableView {
var openSelection: (() -> Void)?
var dismiss: (() -> Void)?
var editSearch: ((NSEvent) -> Void)?
override func keyDown(with event: NSEvent) {
switch event.keyCode {
case 36, 76:
openSelection?()
case 53:
dismiss?()
default:
let isEditing = event.keyCode == 51 || event.keyCode == 117
|| (event.characters?.unicodeScalars.contains { !CharacterSet.controlCharacters.contains($0) } ?? false)
if isEditing && event.modifierFlags.intersection([.command, .control]).isEmpty {
editSearch?(event)
} else {
super.keyDown(with: event)
}
}
}
}