Files

410 lines
16 KiB
Swift

import Foundation
import RunnerCore
/// Whether the LaunchAgent is installed and running.
public struct ServiceStatus: Sendable, Equatable {
/// Whether the plist exists at ``LaunchdService/agentPlistURL``.
public let installed: Bool
/// Whether `launchctl` reports the label as loaded.
public let loaded: Bool
/// The running PID, when loaded and alive.
public let pid: Int?
/// The last exit status `launchctl` reported, when not running.
public let lastExitStatus: Int?
/// Path to the plist, whether or not it exists.
public let plistPath: String
public init(
installed: Bool,
loaded: Bool,
pid: Int? = nil,
lastExitStatus: Int? = nil,
plistPath: String
) {
self.installed = installed
self.loaded = loaded
self.pid = pid
self.lastExitStatus = lastExitStatus
self.plistPath = plistPath
}
}
/// Installs, removes, and inspects the daemon's `launchd` job.
///
/// ## LaunchAgent, never LaunchDaemon
///
/// This is not a stylistic choice. Two hard constraints force it:
///
/// * Virtualization.framework needs a **GUI login session**. A LaunchDaemon runs
/// in the system context with no session, and VM startup fails there.
/// * From macOS 15, starting a VM requires an **unlocked `login.keychain`**.
/// That keychain unlocks when a user logs in graphically; a LaunchDaemon never
/// sees it.
///
/// So the daemon runs as a LaunchAgent in the logged-in user's session, and the
/// host must be configured for automatic login with the screen allowed to sleep
/// but the session never locked. `doctor` checks the keychain state precisely
/// because this is the failure people hit first.
public enum LaunchdService {
/// The `launchd` label, matching `CFBundleIdentifier`.
public static let label = "xyz.blakeslee.gitea-macos-vm-orchestrator"
/// Labels this service used to install under.
///
/// Renaming the label renames the plist, so an upgrade that only wrote the
/// new one would leave the old job bootstrapped and still running the old
/// binary — two daemons polling the same Gitea instance, racing to claim
/// the same queued jobs, with no hint in the logs that a second one exists.
/// ``install(executablePath:configPath:)`` and ``uninstall()`` therefore
/// evict these first. Append, never edit, when the label changes again.
public static let legacyLabels = ["xyz.blakeslee.gitea-macos-runner"]
/// `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-vm-orchestrator.plist`.
public static var agentPlistURL: URL {
agentPlistURL(for: label)
}
/// The LaunchAgent plist path for an arbitrary label.
public static func agentPlistURL(for label: String) -> URL {
URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/LaunchAgents/\(label).plist"))
}
/// The default install location of the signed app's executable.
///
/// `make install` puts the bundle here; the entitlement only exists on the
/// signed bundle, so this — not a bare binary — is what `launchd` must run.
public static let defaultExecutablePath =
"~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner"
/// The GUI domain target for this user, e.g. `gui/501`.
public static var domainTarget: String { "gui/\(getuid())" }
/// The service target for this user's agent, e.g. `gui/501/xyz.blakeslee…`.
public static var serviceTarget: String { "\(domainTarget)/\(label)" }
/// Writes the plist and loads the job.
///
/// `ProgramArguments` is the **installed app bundle's** executable followed
/// by `daemon` — not `.build/…` and not a bare binary, because the
/// entitlement only exists on the signed bundle. `RunAtLoad` and `KeepAlive`
/// are both set so the daemon survives crashes and logins.
///
/// - Parameters:
/// - executablePath: Absolute path to the installed binary, e.g.
/// `~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner`.
/// - configPath: Optional `--config` argument for a non-default location.
/// - Throws: ``CoreError/notFound(_:)`` when the executable or the template
/// is missing, ``CoreError/processFailed(command:exitCode:output:)`` when
/// `launchctl` refuses the job.
public static func install(executablePath: String, configPath: String? = nil) throws {
let executable = RunnerConfig.expandTilde(executablePath)
guard FileManager.default.isExecutableFile(atPath: executable) else {
throw CoreError.notFound(
"""
no executable at \(executable) — run `make install` to build, sign, \
and install the app bundle first
"""
)
}
var arguments = ["daemon"]
if let configPath {
arguments += ["--config", RunnerConfig.expandTilde(configPath)]
}
let xml = try renderPlist(executablePath: executable, arguments: arguments)
let fm = FileManager.default
try fm.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true)
try fm.createDirectory(
at: agentPlistURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
// Upgrading from a build that installed under an older label: evict it
// before bootstrapping this one, or both run at once. See
// ``legacyLabels``.
removeLegacyAgents()
// A reinstall over a loaded job is the common case (upgrade, config
// change), so unload before rewriting rather than failing on "already
// bootstrapped".
if fm.fileExists(atPath: agentPlistURL.path) {
_ = try? uninstallJobOnly()
}
do {
try Data(xml.utf8).write(to: agentPlistURL, options: .atomic)
} catch {
throw CoreError.processFailed(
command: "write \(agentPlistURL.path)",
exitCode: 1,
output: error.localizedDescription
)
}
let bootstrap = LaunchdShell.run("/bin/launchctl", ["bootstrap", domainTarget, agentPlistURL.path])
if bootstrap.exitCode != 0 {
// `bootstrap` is the modern verb but is unavailable in some session
// contexts (and returns 5 for "input/output error" on odd domains);
// the legacy loader still works there.
let legacy = LaunchdShell.run("/bin/launchctl", ["load", "-w", agentPlistURL.path])
if legacy.exitCode != 0 {
throw CoreError.processFailed(
command: "launchctl bootstrap \(domainTarget) \(agentPlistURL.path)",
exitCode: bootstrap.exitCode,
output: (bootstrap.output + "\n" + legacy.output).trimmingCharacters(in: .whitespacesAndNewlines)
)
}
}
}
/// Unloads the job and removes the plist. Safe when not installed.
///
/// Also evicts any ``legacyLabels`` job, so `service uninstall` leaves
/// nothing of this project loaded regardless of which version installed it.
public static func uninstall() throws {
removeLegacyAgents()
_ = try? uninstallJobOnly()
if FileManager.default.fileExists(atPath: agentPlistURL.path) {
try FileManager.default.removeItem(at: agentPlistURL)
}
}
/// Boots out and deletes any LaunchAgent installed under a ``legacyLabels``
/// entry.
///
/// Best effort by design: a legacy job that was never installed, is not
/// loaded, or whose plist is already gone is not an error, and failing to
/// evict one must not block installing the current job.
///
/// - Returns: The legacy labels that were actually found and removed, for
/// callers that want to tell the operator a migration happened.
@discardableResult
public static func removeLegacyAgents() -> [String] {
var removed: [String] = []
for legacy in legacyLabels {
let plist = agentPlistURL(for: legacy)
let bootout = LaunchdShell.run(
"/bin/launchctl", ["bootout", "\(domainTarget)/\(legacy)"])
if bootout.exitCode != 0 {
_ = LaunchdShell.run("/bin/launchctl", ["unload", "-w", plist.path])
}
if FileManager.default.fileExists(atPath: plist.path) {
try? FileManager.default.removeItem(at: plist)
removed.append(legacy)
} else if bootout.exitCode == 0 {
// Loaded, but from a plist that is no longer on disk.
removed.append(legacy)
}
}
return removed
}
/// Unloads the job but leaves the plist on disk.
private static func uninstallJobOnly() throws {
let bootout = LaunchdShell.run("/bin/launchctl", ["bootout", serviceTarget])
if bootout.exitCode != 0 {
_ = LaunchdShell.run("/bin/launchctl", ["unload", "-w", agentPlistURL.path])
}
}
/// Reports installation and run state.
public static func status() throws -> ServiceStatus {
let installed = FileManager.default.fileExists(atPath: agentPlistURL.path)
let printed = LaunchdShell.run("/bin/launchctl", ["print", serviceTarget])
guard printed.exitCode == 0 else {
// 113 (EAGAIN-ish "Could not find service") and 36 are both "not
// loaded"; anything else is still, for our purposes, not loaded.
return ServiceStatus(installed: installed, loaded: false, plistPath: agentPlistURL.path)
}
return ServiceStatus(
installed: installed,
loaded: true,
pid: firstInteger(in: printed.output, key: "pid"),
lastExitStatus: firstInteger(in: printed.output, key: "last exit code"),
plistPath: agentPlistURL.path
)
}
/// Extracts `key = <integer>` from `launchctl print` output.
private static func firstInteger(in output: String, key: String) -> Int? {
for line in output.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix(key) else { continue }
guard let equals = trimmed.firstIndex(of: "=") else { continue }
let value = trimmed[trimmed.index(after: equals)...].trimmingCharacters(in: .whitespaces)
return Int(value)
}
return nil
}
/// Renders `Resources/launchd.plist.template` with the given substitutions.
///
/// Placeholders: `{{LABEL}}`, `{{PROGRAM}}`, `{{ARGUMENTS}}`,
/// `{{STDOUT_PATH}}`, `{{STDERR_PATH}}`.
///
/// - Parameters:
/// - executablePath: Absolute path to the installed binary.
/// - arguments: Arguments after the executable, e.g. `["daemon"]`.
/// - Returns: The plist XML.
public static func renderPlist(executablePath: String, arguments: [String]) throws -> String {
let template = try loadTemplate()
let argumentXML = arguments
.map { "\t\t<string>\(xmlEscape($0))</string>" }
.joined(separator: "\n")
return template
.replacingOccurrences(of: "{{LABEL}}", with: xmlEscape(label))
.replacingOccurrences(of: "{{PROGRAM}}", with: xmlEscape(executablePath))
.replacingOccurrences(of: "{{ARGUMENTS}}", with: argumentXML)
.replacingOccurrences(
of: "{{STDOUT_PATH}}",
with: xmlEscape(logDirectoryURL.appendingPathComponent("daemon.out.log").path)
)
.replacingOccurrences(
of: "{{STDERR_PATH}}",
with: xmlEscape(logDirectoryURL.appendingPathComponent("daemon.err.log").path)
)
}
/// Locates the plist template.
///
/// The template is not an SPM resource bundle and `make bundle` copies only
/// `Info.plist` into the app, so there is no single reliable location: this
/// walks the plausible ones and falls back to a built-in copy so
/// `service install` works from the installed app, from `swift run`, and from
/// a checkout.
private static func loadTemplate() throws -> String {
var candidates: [URL] = []
if let resourceURL = Bundle.main.url(forResource: "launchd.plist", withExtension: "template") {
candidates.append(resourceURL)
}
candidates.append(
Bundle.main.bundleURL
.appendingPathComponent("Contents/Resources/launchd.plist.template")
)
if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() {
let directory = executableURL.deletingLastPathComponent()
candidates.append(directory.appendingPathComponent("Resources/launchd.plist.template"))
candidates.append(
directory.deletingLastPathComponent()
.appendingPathComponent("Resources/launchd.plist.template")
)
}
// Sources/RunnerHost/LaunchdService.swift → repository root.
let repositoryRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
candidates.append(repositoryRoot.appendingPathComponent("Resources/launchd.plist.template"))
candidates.append(
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
.appendingPathComponent("Resources/launchd.plist.template")
)
for candidate in candidates {
if let contents = try? String(contentsOf: candidate, encoding: .utf8) {
return contents
}
}
return embeddedTemplate
}
/// Escapes a string for an XML text node.
private static func xmlEscape(_ value: String) -> String {
value
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
/// Directory for the agent's stdout/stderr logs,
/// `~/Library/Logs/gitea-macos-runner`.
public static var logDirectoryURL: URL {
URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/Logs/gitea-macos-runner"), isDirectory: true)
}
/// Byte-for-byte fallback copy of `Resources/launchd.plist.template`, used
/// when the file cannot be found next to the running binary.
private static let embeddedTemplate = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
\t<key>Label</key>
\t<string>{{LABEL}}</string>
\t<key>ProgramArguments</key>
\t<array>
\t\t<string>{{PROGRAM}}</string>
{{ARGUMENTS}}
\t</array>
\t<key>RunAtLoad</key>
\t<true/>
\t<key>KeepAlive</key>
\t<dict>
\t\t<key>SuccessfulExit</key>
\t\t<false/>
\t</dict>
\t<key>ThrottleInterval</key>
\t<integer>30</integer>
\t<key>ProcessType</key>
\t<string>Interactive</string>
\t<key>StandardOutPath</key>
\t<string>{{STDOUT_PATH}}</string>
\t<key>StandardErrorPath</key>
\t<string>{{STDERR_PATH}}</string>
\t<key>EnvironmentVariables</key>
\t<dict>
\t\t<key>PATH</key>
\t\t<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
\t</dict>
</dict>
</plist>
"""
}
/// Minimal synchronous process runner for `launchctl`.
private enum LaunchdShell {
struct Output {
let exitCode: Int32
let output: String
}
static func run(_ launchPath: String, _ arguments: [String]) -> Output {
let process = Process()
process.executableURL = URL(fileURLWithPath: launchPath)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
} catch {
return Output(exitCode: 127, output: "\(error)")
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return Output(
exitCode: process.terminationStatus,
output: String(data: data, encoding: .utf8) ?? ""
)
}
}