import AppKit import ArgumentParser import Foundation import Logging import RunnerCore import RunnerHost /// `gitea-macos-runner daemon` — the long-running service. /// /// ## Why there is an `NSApplication` here /// /// Virtualization.framework requires a running main run loop in an application /// context; a plain command-line process that blocks in `await` never services /// it, and VM startup either hangs or fails. The fix is to start a real /// `NSApplication` but suppress every trace of a GUI: /// /// ```swift /// NSApplication.shared.setActivationPolicy(.prohibited) // no Dock icon, no menu bar /// // spawn the orchestrator Task /// NSApplication.shared.run() // never returns /// ``` /// /// `.prohibited` (mirrored by `LSUIElement` in `Info.plist`) is what makes this /// invisible. The orchestrator runs in a detached `Task`; `run()` owns the main /// thread from then on. /// /// `SIGTERM` and `SIGINT` are trapped with `DispatchSourceSignal` — not /// `signal(2)` handlers, which cannot safely touch Swift concurrency — and /// trigger ``Orchestrator/shutdown()`` before the process leaves, so guests get /// a chance to stop cleanly instead of having their disks yanked. struct DaemonCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "daemon", abstract: "Watch Gitea for queued macOS jobs and run each in a fresh VM." ) @OptionGroup var options: GlobalOptions /// Base image to clone for each job. @Option(name: .long, help: "Base image to clone for each job.") var image: String = "default" /// Run one poll/reconcile tick and exit. Useful for debugging without /// installing the service. @Flag(name: .long, help: "Run a single scheduling tick, then exit.") var once: Bool = false func run() async throws { CLI.bootstrapLogging(verbose: options.verbose) let logger = Logger(label: "daemon") let config = try options.loadConfig() guard let adminToken = try config.resolveAdminToken(), !adminToken.isEmpty else { throw ValidationError( """ no Gitea admin token: set gitea.adminTokenFile (preferred) or gitea.adminToken \ in \(RunnerConfig.expandTilde(options.configPath)) """ ) } for path in config.insecureTokenFilePaths { logger.warning("token file is group/world readable", metadata: ["path": .string(path)]) } let store = VMStore(config: config) try store.ensureLayout() guard try store.image(named: image) != nil else { throw ValidationError( "no base image named '\(image)' — build one with `gitea-macos-runner image build --name \(image)`" ) } let client = GiteaClient(baseURL: config.gitea.instanceURL, token: adminToken) let orchestrator = Orchestrator( config: config, client: client, store: store, imageName: image, logger: Logger(label: "orchestrator") ) let singleTick = once let jobTimeout = TimeInterval(config.scheduler.jobTimeoutMinutes * 60) // Even a single tick can start a VM, and a VM needs the run loop — so // both modes go through NSApplication. await VZAppRuntime.run( onSignal: { await orchestrator.shutdown() }, body: { do { if singleTick { await orchestrator.reconcileOnce() await orchestrator.tick() // Let whatever the tick started run to completion rather // than tearing a just-booted guest down mid-boot. let deadline = Date().addingTimeInterval(jobTimeout) var pending = await orchestrator.liveVMs().count while pending > 0, Date() < deadline { try? await Task.sleep(for: .seconds(5)) pending = await orchestrator.liveVMs().count } await orchestrator.shutdown() } else { try await orchestrator.runForever() } } catch is CancellationError { // Expected on shutdown. } catch { logger.critical("daemon stopped", metadata: ["error": .string("\(error)")]) // Fully qualified: inside a ParsableCommand a bare `exit` // resolves to ParsableCommand.exit(withError:). await MainActor.run { Foundation.exit(1) } } } ) } } /// Hosts an `NSApplication` run loop so Virtualization.framework has the main /// run loop it requires, while the real work runs in a `Task`. /// /// Shared by `daemon` and `vm boot`: any command that starts a VM needs this. @MainActor enum VZAppRuntime { /// Signal sources have to outlive the call that creates them or they are /// cancelled on deinit and the signals go nowhere. private static var signalSources: [DispatchSourceSignal] = [] private static var isTerminating = false /// Starts the run loop and runs `body` alongside it. Never returns. /// /// - Parameters: /// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting. /// - body: The work to run. When it returns, the process exits zero. static func run( onSignal: @escaping @Sendable () async -> Void, body: @escaping @Sendable () async -> Void ) -> Never { let app = NSApplication.shared // No Dock icon, no menu bar, no activation: this is a background agent // that merely needs to be an application as far as the kernel is // concerned. app.setActivationPolicy(.prohibited) for signalNumber in [SIGINT, SIGTERM] { // DispatchSourceSignal only observes; the default disposition still // kills the process unless it is ignored first. signal(signalNumber, SIG_IGN) let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main) source.setEventHandler { Task { @MainActor in guard !isTerminating else { return } isTerminating = true CLI.note("received signal; shutting down…") await onSignal() NSApp.terminate(nil) exit(0) } } source.resume() signalSources.append(source) } Task { await body() await MainActor.run { NSApp.terminate(nil) exit(0) } } app.run() exit(0) } }