132 lines
5.7 KiB
Swift
132 lines
5.7 KiB
Swift
import Foundation
|
|
|
|
public struct Application: Identifiable, Sendable {
|
|
public enum Command: String, CaseIterable, Sendable {
|
|
case translate
|
|
case emoji
|
|
case notes
|
|
case toggleTheme
|
|
|
|
public var presentsView: Bool {
|
|
switch self {
|
|
case .translate, .emoji, .notes: true
|
|
case .toggleTheme: false
|
|
}
|
|
}
|
|
}
|
|
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 searchableText: FuzzySearchText
|
|
|
|
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"
|
|
case .toggleTheme: "Toggle System Theme"
|
|
}
|
|
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
|
|
searchableText = FuzzySearchText(name)
|
|
}
|
|
}
|
|
|
|
public enum ApplicationCatalog {
|
|
public static var defaultRoots: [URL] {
|
|
[
|
|
URL(fileURLWithPath: "/Applications", isDirectory: true),
|
|
URL(fileURLWithPath: "/System/Applications", isDirectory: true),
|
|
URL(fileURLWithPath: "/System/Cryptexes/App/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] {
|
|
FuzzySearchText.results(
|
|
for: query, in: applications, text: { $0.searchableText }, breakingTiesWith: preferred
|
|
)
|
|
}
|
|
}
|
|
|
|
private func nonempty(_ name: String?) -> String? {
|
|
guard let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
|
return name
|
|
}
|
|
|
|
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
|
|
}
|