diff --git a/Makefile b/Makefile index 7224196..ccc3484 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ -.PHONY: build run test +.PHONY: build run install test APP := .build/Conjure.app +INSTALL_DIR ?= /Applications build: swift build -c release @@ -12,5 +13,8 @@ build: run: build swift Scripts/run.swift "$(APP)" +install: build + swift Scripts/install.swift "$(APP)" "$(INSTALL_DIR)" + test: swift test diff --git a/README.md b/README.md index dcbbb6d..dbcef1f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A native macOS application launcher. Open it normally, search for an application, and press Return to open it or bring its existing instance forward. Global shortcuts are deliberately deferred. -## Build and run +## Build, run, and install Requires macOS 14 or later and a Swift 6 toolchain (Xcode 16 or later). @@ -14,9 +14,18 @@ This builds a release executable, assembles an ad-hoc-signed application at `.bu ```sh make build # Build the application without opening it. -make test # Run the discovery and search behavior tests. +make install # Install the application at /Applications/Conjure.app. +make test # Run behavior and installation safety tests. ``` +`make install` rebuilds the signed bundle and installs it without launching it. To install for your user only, or if `/Applications` is not writable: + +```sh +make install INSTALL_DIR="$HOME/Applications" +``` + +Installation verifies a temporary copy before replacing the entire existing bundle, so removed resources do not linger. Quit the app at the destination before reinstalling; other running copies are left alone. Unrelated apps and symbolic-link destinations are not replaced. Installation does not request administrator privileges, and the signature remains local/ad-hoc rather than notarized. + Open `Package.swift` in Xcode to edit the project. Use the bundled app above to run it with its application identity and metadata. ## Controls diff --git a/Scripts/install.swift b/Scripts/install.swift new file mode 100644 index 0000000..0422a8a --- /dev/null +++ b/Scripts/install.swift @@ -0,0 +1,63 @@ +import AppKit + +struct InstallError: LocalizedError { + let errorDescription: String? +} + +do { + guard CommandLine.arguments.count == 3, !CommandLine.arguments[2].isEmpty else { + throw InstallError(errorDescription: "Usage: swift Scripts/install.swift ") + } + let manager = FileManager.default + let identifier = "dev.conjure.launcher" + let source = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + .standardizedFileURL.resolvingSymlinksInPath() + let directory = URL(fileURLWithPath: (CommandLine.arguments[2] as NSString).expandingTildeInPath, isDirectory: true) + .standardizedFileURL.resolvingSymlinksInPath() + let destination = directory.appendingPathComponent("Conjure.app", isDirectory: true) + guard source.path != destination.path else { + throw InstallError(errorDescription: "The build and installation paths must be different.") + } + guard Bundle(url: source)?.bundleIdentifier == identifier else { + throw InstallError(errorDescription: "The source is not a Conjure application bundle.") + } + + func validateDestination() throws -> Bool { + let exists = manager.fileExists(atPath: destination.path) + if exists { + guard try destination.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink != true, + Bundle(url: destination)?.bundleIdentifier == identifier else { + throw InstallError(errorDescription: "Refusing to replace an unrelated app or symbolic link at \(destination.path).") + } + } + let running = NSRunningApplication.runningApplications(withBundleIdentifier: identifier) + .contains { $0.bundleURL?.resolvingSymlinksInPath().path == destination.path } + guard !running else { + throw InstallError(errorDescription: "Quit the installed Conjure at \(destination.path) before reinstalling.") + } + return exists + } + + _ = try validateDestination() + try manager.createDirectory(at: directory, withIntermediateDirectories: true) + let staging = directory.appendingPathComponent(".Conjure-install-\(UUID().uuidString).app", isDirectory: true) + defer { try? manager.removeItem(at: staging) } + try manager.copyItem(at: source, to: staging) + let verification = Process() + verification.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + verification.arguments = ["--verify", "--strict", staging.path] + try verification.run() + verification.waitUntilExit() + guard verification.terminationStatus == 0 else { + throw InstallError(errorDescription: "Bundle signature verification failed; the installed app was not changed.") + } + if try validateDestination() { + _ = try manager.replaceItemAt(destination, withItemAt: staging, options: .usingNewMetadataOnly) + } else { + try manager.moveItem(at: staging, to: destination) + } + print("Installed Conjure at \(destination.path).") +} catch { + FileHandle.standardError.write(Data("Installation failed: \(error.localizedDescription)\n".utf8)) + exit(1) +} diff --git a/Tests/ConjureKitTests/InstallationTests.swift b/Tests/ConjureKitTests/InstallationTests.swift new file mode 100644 index 0000000..911a387 --- /dev/null +++ b/Tests/ConjureKitTests/InstallationTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing + +@Test func installationRefusesToReplaceAnUnrelatedApplication() throws { + let manager = FileManager.default + let root = manager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? manager.removeItem(at: root) } + let source = root.appendingPathComponent("Source/Conjure.app", isDirectory: true) + let contents = source.appendingPathComponent("Contents", isDirectory: true) + let executables = contents.appendingPathComponent("MacOS", isDirectory: true) + try manager.createDirectory(at: executables, withIntermediateDirectories: true) + try manager.copyItem(at: URL(fileURLWithPath: "/usr/bin/true"), to: executables.appendingPathComponent("Conjure")) + let info: [String: Any] = [ + "CFBundleIdentifier": "dev.conjure.launcher", + "CFBundleExecutable": "Conjure", + "CFBundlePackageType": "APPL", + "CFBundleName": "Conjure", + ] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: contents.appendingPathComponent("Info.plist")) + let signing = try installationCommand("/usr/bin/codesign", ["--force", "--sign", "-", source.path]) + guard signing.status == 0 else { throw InstallationFixtureError(output: signing.output) } + + let directory = root.appendingPathComponent("Applications", isDirectory: true) + let unrelated = directory.appendingPathComponent("Conjure.app", isDirectory: true) + let unrelatedContents = unrelated.appendingPathComponent("Contents", isDirectory: true) + try manager.createDirectory(at: unrelatedContents, withIntermediateDirectories: true) + try PropertyListSerialization.data(fromPropertyList: ["CFBundleIdentifier": "example.unrelated"], format: .xml, options: 0) + .write(to: unrelatedContents.appendingPathComponent("Info.plist")) + let marker = unrelated.appendingPathComponent("UserData.txt") + try Data("Keep this unrelated app".utf8).write(to: marker) + let repository = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let result = try installationCommand("/usr/bin/swift", [repository.appendingPathComponent("Scripts/install.swift").path, source.path, directory.path]) + #expect(result.status != 0) + #expect(try String(contentsOf: marker, encoding: .utf8) == "Keep this unrelated app") + #expect(Bundle(url: unrelated)?.bundleIdentifier == "example.unrelated") +} + +private struct InstallationFixtureError: Error { + let output: String +} + +private func installationCommand(_ executable: String, _ arguments: [String]) throws -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return (process.terminationStatus, String(decoding: data, as: UTF8.self)) +}