From 0d033667ff32d84a02ab3eb3df90d1e2be3db3b2 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 18:16:01 +0200 Subject: [PATCH] feat: add native macOS application launcher --- .gitignore | 3 + Makefile | 16 + Package.swift | 13 + README.md | 45 +++ Resources/Info.plist | 30 ++ Sources/Conjure/AppLauncher.swift | 21 ++ Sources/Conjure/ConjureApp.swift | 100 +++++++ .../Conjure/LauncherWindowController.swift | 280 ++++++++++++++++++ Sources/ConjureKit/Applications.swift | 145 +++++++++ Tests/ConjureKitTests/ApplicationsTests.swift | 127 ++++++++ 10 files changed, 780 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Resources/Info.plist create mode 100644 Sources/Conjure/AppLauncher.swift create mode 100644 Sources/Conjure/ConjureApp.swift create mode 100644 Sources/Conjure/LauncherWindowController.swift create mode 100644 Sources/ConjureKit/Applications.swift create mode 100644 Tests/ConjureKitTests/ApplicationsTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29a1e94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.build/ +.swiftpm/ +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..489308d --- /dev/null +++ b/Makefile @@ -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 diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b20eff8 --- /dev/null +++ b/Package.swift @@ -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"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..17a4423 --- /dev/null +++ b/README.md @@ -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:)). diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..ec7c997 --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Conjure + CFBundleIdentifier + dev.conjure.launcher + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Conjure + CFBundleDisplayName + Conjure + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/Sources/Conjure/AppLauncher.swift b/Sources/Conjure/AppLauncher.swift new file mode 100644 index 0000000..ee57b69 --- /dev/null +++ b/Sources/Conjure/AppLauncher.swift @@ -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) 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() + } + } + } + } +} diff --git a/Sources/Conjure/ConjureApp.swift b/Sources/Conjure/ConjureApp.swift new file mode 100644 index 0000000..bc4edde --- /dev/null +++ b/Sources/Conjure/ConjureApp.swift @@ -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 + } +} diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift new file mode 100644 index 0000000..97f50ca --- /dev/null +++ b/Sources/Conjure/LauncherWindowController.swift @@ -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? + 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) + } + } + } +} diff --git a/Sources/ConjureKit/Applications.swift b/Sources/ConjureKit/Applications.swift new file mode 100644 index 0000000..97b4085 --- /dev/null +++ b/Sources/ConjureKit/Applications.swift @@ -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() + var visitedApplications = Set() + 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 +} diff --git a/Tests/ConjureKitTests/ApplicationsTests.swift b/Tests/ConjureKitTests/ApplicationsTests.swift new file mode 100644 index 0000000..d467c4f --- /dev/null +++ b/Tests/ConjureKitTests/ApplicationsTests.swift @@ -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 +}