build: add guarded installation of the macOS app bundle
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
.PHONY: build run test
|
.PHONY: build run install test
|
||||||
|
|
||||||
APP := .build/Conjure.app
|
APP := .build/Conjure.app
|
||||||
|
INSTALL_DIR ?= /Applications
|
||||||
|
|
||||||
build:
|
build:
|
||||||
swift build -c release
|
swift build -c release
|
||||||
@@ -12,5 +13,8 @@ build:
|
|||||||
run: build
|
run: build
|
||||||
swift Scripts/run.swift "$(APP)"
|
swift Scripts/run.swift "$(APP)"
|
||||||
|
|
||||||
|
install: build
|
||||||
|
swift Scripts/install.swift "$(APP)" "$(INSTALL_DIR)"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
swift test
|
swift test
|
||||||
|
|||||||
@@ -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.
|
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).
|
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
|
```sh
|
||||||
make build # Build the application without opening it.
|
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.
|
Open `Package.swift` in Xcode to edit the project. Use the bundled app above to run it with its application identity and metadata.
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|||||||
@@ -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 <Conjure.app> <installation directory>")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user