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