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 = ` 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\(xmlEscape($0))" } .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: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") } /// 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 = """ \tLabel \t{{LABEL}} \tProgramArguments \t \t\t{{PROGRAM}} {{ARGUMENTS}} \t \tRunAtLoad \t \tKeepAlive \t \t\tSuccessfulExit \t\t \t \tThrottleInterval \t30 \tProcessType \tInteractive \tStandardOutPath \t{{STDOUT_PATH}} \tStandardErrorPath \t{{STDERR_PATH}} \tEnvironmentVariables \t \t\tPATH \t\t/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin \t """ } /// 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) ?? "" ) } }