feat: add native macOS application launcher
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user