Files
conjure/Sources/ConjureKit/Applications.swift
T
2026-10-15 11:54:53 +02:00

196 lines
8.6 KiB
Swift

import Foundation
public struct Application: Identifiable, Sendable {
public enum Command: String, CaseIterable, Sendable {
case translate
}
public static let commands = Command.allCases.map { Application(command: $0) }
public let command: Command?
public var id: URL { url }
public let url: URL
public let name: String
fileprivate let searchableName: String
fileprivate let searchableCharacters: [Character]
fileprivate let searchableWordStarts: [Int]
public init(url: URL, name: String) {
self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name)
}
public init(command: Command) {
let name = switch command {
case .translate: "Conjure Translate"
}
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command)
}
fileprivate init(canonicalURL: URL, name: String, command: Command? = nil) {
self.command = command
url = command != nil || canonicalURL.hasDirectoryPath
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
self.name = name
searchableName = folded(name)
let characters = Array(searchableName)
searchableCharacters = characters
var starts: [Int] = []
var previousWasWordCharacter = false
for index in characters.indices {
let isWordCharacter = characters[index].isLetter || characters[index].isNumber
if isWordCharacter && !previousWasWordCharacter { starts.append(index) }
previousWasWordCharacter = isWordCharacter
}
searchableWordStarts = starts
}
}
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),
let packageType = bundle.object(forInfoDictionaryKey: "CFBundlePackageType") as? String,
packageType == "APPL" || packageType == "XPC!",
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] {
results(for: query, in: applications, breakingTiesWith: alphabetical)
}
static func results(
for query: String, in applications: [Application],
breakingTiesWith preferred: (Application, Application) -> Bool
) -> [Application] {
let query = folded(query.trimmingCharacters(in: .whitespacesAndNewlines))
guard !query.isEmpty else { return applications.sorted(by: preferred) }
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 application.searchableWordStarts.contains(where: {
$0 > 0 && application.searchableCharacters[$0...].starts(with: characters)
}) {
return (application, Rank(category: 2))
}
if matchesWordInitials(characters, in: application) {
return (application, Rank(category: 3))
}
if name.contains(query) { return (application, Rank(category: 4)) }
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 preferred(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)
}
}
private static func matchesWordInitials(_ query: [Character], in application: Application) -> Bool {
var matched = 0
for start in application.searchableWordStarts {
guard application.searchableCharacters[start] == query[matched] else { continue }
matched += 1
if matched == query.count { return true }
}
return false
}
// 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: 5, 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)
}
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.absoluteString < rhs.url.absoluteString
}