import Foundation public struct Application: Identifiable, Sendable { public enum Command: String, CaseIterable, Sendable { case translate case emoji case notes } 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" case .emoji: "Conjure Emoji" case .notes: "Conjure Notes" } 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() 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), 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 }