feat: add native macOS application launcher
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,16 @@
|
||||
.PHONY: build run test
|
||||
|
||||
APP := .build/Conjure.app
|
||||
|
||||
build:
|
||||
swift build -c release
|
||||
mkdir -p $(APP)/Contents/MacOS
|
||||
cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure
|
||||
cp Resources/Info.plist $(APP)/Contents/Info.plist
|
||||
codesign --force --sign - $(APP)
|
||||
|
||||
run: build
|
||||
open $(APP)
|
||||
|
||||
test:
|
||||
swift test
|
||||
@@ -0,0 +1,13 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Conjure",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [.executable(name: "Conjure", targets: ["Conjure"])],
|
||||
targets: [
|
||||
.target(name: "ConjureKit"),
|
||||
.executableTarget(name: "Conjure", dependencies: ["ConjureKit"]),
|
||||
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
# Conjure
|
||||
|
||||
A native macOS application launcher. Open it normally, search for an application, and press Return to open it or bring its existing instance forward. Global shortcuts are deliberately deferred.
|
||||
|
||||
## Build and run
|
||||
|
||||
Requires macOS 14 or later and a Swift 6 toolchain (Xcode 16 or later).
|
||||
|
||||
```sh
|
||||
make run
|
||||
```
|
||||
|
||||
This builds a release executable, assembles an ad-hoc-signed application at `.build/Conjure.app`, and opens it through Launch Services. You can also open that app directly in Finder. The local signature is for development, not notarized distribution.
|
||||
|
||||
```sh
|
||||
make build # Build the application without opening it.
|
||||
make test # Run the discovery and search behavior tests.
|
||||
```
|
||||
|
||||
Open `Package.swift` in Xcode to edit the project. Use the bundled app above to run it with its application identity and metadata.
|
||||
|
||||
## Controls
|
||||
|
||||
- Type to filter applications; the first match is selected automatically.
|
||||
- Up/Down changes the selection without leaving search.
|
||||
- Return, keypad Enter, the Open Application button, or a double-click opens the selection.
|
||||
- Escape, closing the window, or switching focus away dismisses the launcher.
|
||||
- Reopen from Finder or the Dock to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
|
||||
- Standard macOS text-editing shortcuts work in the search field.
|
||||
|
||||
## Discovery and search
|
||||
|
||||
Applications are discovered in `/Applications`, `/System/Applications`, `/System/Library/CoreServices/Applications`, and `~/Applications`, including nested folders. Discovery runs off the UI thread and refreshes whenever the launcher is presented. Hidden items, invalid bundles, and helpers inside application packages are excluded; symlinked applications are deduplicated by their canonical paths.
|
||||
|
||||
Search is case- and accent-insensitive. Ranking is exact name, prefix, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Ties and empty searches are alphabetical. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher.
|
||||
|
||||
## Native UI
|
||||
|
||||
AppKit owns the window chrome, search field, inset table selection, application icons, buttons, menus, fonts, and semantic colors. Appearance follows macOS light/dark settings; no custom-painted Raycast skin or third-party UI dependencies.
|
||||
|
||||
- `Sources/Conjure`: application lifecycle, native window, and application launching.
|
||||
- `Sources/ConjureKit`: filesystem discovery and search ranking.
|
||||
- `Tests/ConjureKitTests`: deterministic discovery and ranking checks.
|
||||
|
||||
The launcher uses Apple's [inset table style](https://developer.apple.com/documentation/appkit/nstableview/style-swift.enum/inset), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Conjure</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>dev.conjure.launcher</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Conjure</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Conjure</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,21 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
enum AppLauncher {
|
||||
static func launch(_ url: URL) async throws {
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
configuration.activates = true
|
||||
configuration.promptsUserIfNeeded = false
|
||||
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
NSWorkspace.shared.openApplication(at: url, configuration: configuration) { _, error in
|
||||
// AppKit calls this completion on a concurrent queue; only resume the continuation here.
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import AppKit
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct ConjureApp {
|
||||
static func main() {
|
||||
let application = NSApplication.shared
|
||||
let delegate = ApplicationDelegate()
|
||||
application.delegate = delegate
|
||||
application.setActivationPolicy(.regular)
|
||||
application.mainMenu = makeMainMenu()
|
||||
|
||||
// NSApplication holds its delegate weakly.
|
||||
withExtendedLifetime(delegate) {
|
||||
application.run()
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeMainMenu() -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let applicationItem = NSMenuItem(title: "Conjure", action: nil, keyEquivalent: "")
|
||||
let applicationMenu = NSMenu(title: "Conjure")
|
||||
applicationItem.submenu = applicationMenu
|
||||
menu.addItem(applicationItem)
|
||||
|
||||
applicationMenu.addItem(
|
||||
withTitle: "About Conjure",
|
||||
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
applicationMenu.addItem(.separator())
|
||||
applicationMenu.addItem(
|
||||
withTitle: "Hide Conjure",
|
||||
action: #selector(NSApplication.hide(_:)),
|
||||
keyEquivalent: "h"
|
||||
)
|
||||
let hideOthers = applicationMenu.addItem(
|
||||
withTitle: "Hide Others",
|
||||
action: #selector(NSApplication.hideOtherApplications(_:)),
|
||||
keyEquivalent: "h"
|
||||
)
|
||||
hideOthers.keyEquivalentModifierMask = [.command, .option]
|
||||
applicationMenu.addItem(
|
||||
withTitle: "Show All",
|
||||
action: #selector(NSApplication.unhideAllApplications(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
applicationMenu.addItem(.separator())
|
||||
applicationMenu.addItem(
|
||||
withTitle: "Quit Conjure",
|
||||
action: #selector(NSApplication.terminate(_:)),
|
||||
keyEquivalent: "q"
|
||||
)
|
||||
|
||||
let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "")
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileItem.submenu = fileMenu
|
||||
menu.addItem(fileItem)
|
||||
fileMenu.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
|
||||
|
||||
let editItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "")
|
||||
let editMenu = NSMenu(title: "Edit")
|
||||
editItem.submenu = editMenu
|
||||
menu.addItem(editItem)
|
||||
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
|
||||
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
||||
editMenu.addItem(.separator())
|
||||
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||||
|
||||
let windowItem = NSMenuItem(title: "Window", action: nil, keyEquivalent: "")
|
||||
let windowMenu = NSMenu(title: "Window")
|
||||
windowItem.submenu = windowMenu
|
||||
menu.addItem(windowItem)
|
||||
windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
|
||||
windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
|
||||
windowMenu.addItem(.separator())
|
||||
windowMenu.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")
|
||||
NSApplication.shared.windowsMenu = windowMenu
|
||||
return menu
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
private lazy var launcher = LauncherWindowController()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
launcher.present()
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
|
||||
launcher.present()
|
||||
return false
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
public struct Application: Identifiable, Sendable {
|
||||
public var id: URL { url }
|
||||
public let url: URL
|
||||
public let name: String
|
||||
fileprivate let searchableName: String
|
||||
fileprivate let searchableCharacters: [Character]
|
||||
|
||||
public init(url: URL, name: String) {
|
||||
self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name)
|
||||
}
|
||||
|
||||
fileprivate init(canonicalURL: URL, name: String) {
|
||||
url = canonicalURL
|
||||
self.name = name
|
||||
searchableName = folded(name)
|
||||
searchableCharacters = Array(searchableName)
|
||||
}
|
||||
}
|
||||
|
||||
public enum ApplicationCatalog {
|
||||
public static var defaultRoots: [URL] {
|
||||
[
|
||||
URL(fileURLWithPath: "/Applications", isDirectory: true),
|
||||
URL(fileURLWithPath: "/System/Applications", isDirectory: true),
|
||||
URL(fileURLWithPath: "/System/Library/CoreServices/Applications", isDirectory: true),
|
||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications", isDirectory: true),
|
||||
]
|
||||
}
|
||||
|
||||
public static func discover(in roots: [URL] = defaultRoots) -> [Application] {
|
||||
let fileManager = FileManager.default
|
||||
var directories = roots
|
||||
var visitedDirectories = Set<URL>()
|
||||
var visitedApplications = Set<URL>()
|
||||
var applications: [Application] = []
|
||||
|
||||
while let directory = directories.popLast() {
|
||||
let canonicalDirectory = directory.standardizedFileURL.resolvingSymlinksInPath()
|
||||
guard canonicalDirectory.pathExtension.lowercased() != "app",
|
||||
visitedDirectories.insert(canonicalDirectory).inserted,
|
||||
let entries = try? fileManager.contentsOfDirectory(
|
||||
at: canonicalDirectory,
|
||||
includingPropertiesForKeys: [.isDirectoryKey, .isHiddenKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { continue }
|
||||
|
||||
for entry in entries {
|
||||
guard let values = try? entry.resourceValues(forKeys: [.isHiddenKey]),
|
||||
values.isHidden != true else { continue }
|
||||
let url = entry.standardizedFileURL.resolvingSymlinksInPath()
|
||||
guard let targetValues = try? url.resourceValues(forKeys: [.isDirectoryKey, .isHiddenKey]),
|
||||
targetValues.isDirectory == true, targetValues.isHidden != true else { continue }
|
||||
|
||||
if entry.pathExtension.lowercased() == "app" || url.pathExtension.lowercased() == "app" {
|
||||
// Never expose helpers inside an application, even if its bundle is invalid.
|
||||
guard visitedApplications.insert(url).inserted,
|
||||
let bundle = Bundle(url: url),
|
||||
bundle.object(forInfoDictionaryKey: "CFBundlePackageType") as? String == "APPL",
|
||||
let executable = bundle.executableURL,
|
||||
let executableValues = try? executable.resourceValues(forKeys: [.isRegularFileKey]),
|
||||
executableValues.isRegularFile == true,
|
||||
fileManager.isExecutableFile(atPath: executable.path) else { continue }
|
||||
let name = nonempty(bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
|
||||
?? nonempty(bundle.object(forInfoDictionaryKey: "CFBundleName") as? String)
|
||||
?? url.deletingPathExtension().lastPathComponent
|
||||
applications.append(Application(canonicalURL: url, name: name))
|
||||
} else {
|
||||
directories.append(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return applications.sorted(by: alphabetical)
|
||||
}
|
||||
}
|
||||
|
||||
public enum ApplicationSearch {
|
||||
public static func results(for query: String, in applications: [Application]) -> [Application] {
|
||||
let query = folded(query.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
guard !query.isEmpty else { return applications.sorted(by: alphabetical) }
|
||||
let characters = Array(query)
|
||||
var ranked = applications.compactMap { application -> (Application, Rank)? in
|
||||
let name = application.searchableName
|
||||
if name == query { return (application, Rank(category: 0)) }
|
||||
if name.hasPrefix(query) { return (application, Rank(category: 1)) }
|
||||
if name.contains(query) { return (application, Rank(category: 2)) }
|
||||
guard let rank = fuzzyRank(characters, in: application.searchableCharacters) else { return nil }
|
||||
return (application, rank)
|
||||
}
|
||||
ranked.sort { lhs, rhs in
|
||||
if lhs.1 != rhs.1 { return lhs.1 < rhs.1 }
|
||||
return alphabetical(lhs.0, rhs.0)
|
||||
}
|
||||
return ranked.map(\.0)
|
||||
}
|
||||
|
||||
private struct Rank: Comparable {
|
||||
let category: Int
|
||||
var gaps: Int = 0
|
||||
var start: Int = 0
|
||||
|
||||
static func < (lhs: Rank, rhs: Rank) -> Bool {
|
||||
(lhs.category, lhs.gaps, lhs.start) < (rhs.category, rhs.gaps, rhs.start)
|
||||
}
|
||||
}
|
||||
|
||||
// ponytail: quadratic in app-name length; use a linear matcher if long command text becomes searchable.
|
||||
private static func fuzzyRank(_ query: [Character], in name: [Character]) -> Rank? {
|
||||
guard query.count <= name.count else { return nil }
|
||||
var best: Rank?
|
||||
for start in name.indices where name[start] == query[0] {
|
||||
var matched = 1
|
||||
var end = start
|
||||
while matched < query.count && end + 1 < name.count {
|
||||
end += 1
|
||||
if name[end] == query[matched] { matched += 1 }
|
||||
}
|
||||
guard matched == query.count else { continue }
|
||||
let rank = Rank(category: 3, gaps: end - start + 1 - query.count, start: start)
|
||||
if let best, best <= rank { continue }
|
||||
best = rank
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
private let searchLocale = Locale(identifier: "en_US_POSIX")
|
||||
|
||||
private func nonempty(_ name: String?) -> String? {
|
||||
guard let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||
return name
|
||||
}
|
||||
|
||||
private func folded(_ string: String) -> String {
|
||||
string.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale)
|
||||
}
|
||||
|
||||
private func alphabetical(_ lhs: Application, _ rhs: Application) -> Bool {
|
||||
let order = lhs.name.localizedStandardCompare(rhs.name)
|
||||
if order != .orderedSame { return order == .orderedAscending }
|
||||
if lhs.name != rhs.name { return lhs.name < rhs.name }
|
||||
return lhs.url.path < rhs.url.path
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import ConjureKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Test func searchRanksExactPrefixSubstringAndFuzzyMatches() {
|
||||
let names = ["Caramel", "My cl Tool", "Cloud", "Cold", "CL", "Unrelated"]
|
||||
let applications = names.map(application)
|
||||
|
||||
#expect(ApplicationSearch.results(for: "cl", in: applications).map(\.name) == [
|
||||
"CL", "Cloud", "My cl Tool", "Cold", "Caramel",
|
||||
])
|
||||
#expect(ApplicationSearch.results(for: "zq", in: applications).isEmpty)
|
||||
}
|
||||
|
||||
@Test func searchFoldsAccentsAndTrimsWhitespace() {
|
||||
let applications = ["Decafé", "Caféteria", "Café", "Camera"].map(application)
|
||||
|
||||
#expect(ApplicationSearch.results(for: " \n CAFÉ \t ", in: applications).map(\.name) == [
|
||||
"Café", "Caféteria", "Decafé",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func fuzzyMatchesPreferCompactSpansThenEarlierStartsAndAlphabeticalTies() {
|
||||
let applications = ["AxxxxB", "xAyB", "AzB", "AyB", "B before A"].map(application)
|
||||
|
||||
#expect(ApplicationSearch.results(for: "ab", in: applications).map(\.name) == [
|
||||
"AyB", "AzB", "xAyB", "AxxxxB",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func emptySearchSortsAlphabeticallyAndBreaksDuplicateNameTiesByURL() {
|
||||
let first = Application(url: URL(fileURLWithPath: "/First/Tools.app"), name: "Tools")
|
||||
let second = Application(url: URL(fileURLWithPath: "/Second/Tools.app"), name: "Tools")
|
||||
let applications = [second, first, application("Alpha")]
|
||||
|
||||
for query in ["", " \n\t "] {
|
||||
#expect(ApplicationSearch.results(for: query, in: applications).map(\.url) == [
|
||||
applications[2].url, first.url, second.url,
|
||||
])
|
||||
}
|
||||
#expect(ApplicationSearch.results(for: "tools", in: applications).map(\.url) == [first.url, second.url])
|
||||
}
|
||||
|
||||
@Test func discoveryFindsNestedAppsButNotHiddenInvalidOrBundledHelpers() throws {
|
||||
let root = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let nested = root.appendingPathComponent("Utilities", isDirectory: true)
|
||||
let alpha = try appBundle(in: nested, filename: "Alpha", name: "Alpha")
|
||||
let parent = try appBundle(in: root, filename: "Parent", name: "Parent")
|
||||
_ = try appBundle(in: parent.appendingPathComponent("Contents/Helpers"), filename: "Helper", name: "Helper")
|
||||
_ = try appBundle(in: root, filename: ".Hidden", name: "Hidden")
|
||||
_ = try appBundle(in: root, filename: "NoExecutable", name: "NoExecutable", executable: false)
|
||||
_ = try appBundle(in: root, filename: "WrongType", name: "WrongType", packageType: "BNDL")
|
||||
let fallback = try appBundle(in: root, filename: "Fallback", name: nil)
|
||||
let display = try appBundle(in: root, filename: "Filename", name: "Internal", displayName: "Unlocalized")
|
||||
let localization = display.appendingPathComponent("Contents/Resources/en.lproj", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: localization, withIntermediateDirectories: true)
|
||||
let localizedInfo = try PropertyListSerialization.data(
|
||||
fromPropertyList: ["CFBundleDisplayName": "Display"], format: .xml, options: 0
|
||||
)
|
||||
try localizedInfo.write(to: localization.appendingPathComponent("InfoPlist.strings"))
|
||||
let invalid = root.appendingPathComponent("Invalid.app", isDirectory: true)
|
||||
_ = try appBundle(in: invalid, filename: "OtherHelper", name: "OtherHelper")
|
||||
|
||||
let applications = ApplicationCatalog.discover(in: [root, root.appendingPathComponent("Missing")])
|
||||
#expect(applications.map(\.name) == ["Alpha", "Display", "Fallback", "Parent"])
|
||||
#expect(applications.map(\.url) == [alpha, display, fallback, parent].map { $0.resolvingSymlinksInPath() })
|
||||
}
|
||||
|
||||
@Test func discoveryCanonicalizesAliasesAndAvoidsDirectorySymlinkCycles() throws {
|
||||
let root = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let nested = root.appendingPathComponent("Nested", isDirectory: true)
|
||||
let app = try appBundle(in: nested, filename: "Editor", name: "Editor")
|
||||
let alias = root.appendingPathComponent("Alias.app")
|
||||
try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: app)
|
||||
try FileManager.default.createSymbolicLink(at: nested.appendingPathComponent("Loop"), withDestinationURL: root)
|
||||
let linkedDirectory = root.appendingPathComponent("LinkedDirectory")
|
||||
try FileManager.default.createSymbolicLink(at: linkedDirectory, withDestinationURL: nested)
|
||||
|
||||
let applications = ApplicationCatalog.discover(in: [root, linkedDirectory, root])
|
||||
#expect(applications.count == 1)
|
||||
#expect(applications.first?.id == app.resolvingSymlinksInPath())
|
||||
#expect(Application(url: alias, name: "Alias").id == applications.first?.id)
|
||||
}
|
||||
|
||||
private func application(_ name: String) -> Application {
|
||||
Application(url: URL(fileURLWithPath: "/Applications/\(name).app"), name: name)
|
||||
}
|
||||
|
||||
private func temporaryDirectory() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func appBundle(
|
||||
in directory: URL,
|
||||
filename: String,
|
||||
name: String?,
|
||||
displayName: String? = nil,
|
||||
executable: Bool = true,
|
||||
packageType: String = "APPL"
|
||||
) throws -> URL {
|
||||
let url = directory.appendingPathComponent("\(filename).app", isDirectory: true)
|
||||
let contents = url.appendingPathComponent("Contents", isDirectory: true)
|
||||
let macOS = contents.appendingPathComponent("MacOS", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true)
|
||||
var info: [String: Any] = [
|
||||
"CFBundlePackageType": packageType,
|
||||
"CFBundleExecutable": "Launcher",
|
||||
"CFBundleDevelopmentRegion": "en",
|
||||
"CFBundleIdentifier": "test.conjure.\(UUID().uuidString)",
|
||||
]
|
||||
info["CFBundleName"] = name
|
||||
info["CFBundleDisplayName"] = displayName
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0)
|
||||
try data.write(to: contents.appendingPathComponent("Info.plist"))
|
||||
if executable {
|
||||
let binary = macOS.appendingPathComponent("Launcher")
|
||||
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: binary)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
||||
}
|
||||
return url
|
||||
}
|
||||
Reference in New Issue
Block a user