Files
nucleic/Sources/NucleicCore/MacVM/MacVMSpec.swift
T

268 lines
14 KiB
Swift

import Foundation
/// Request to bring up a **macOS guest VM** (Apple's `Virtualization` framework) for a session, so
/// an agent can run Mac-only work — `xcodebuild`, `xcrun simctl`, `codesign`, simulator-driven
/// end-to-end tests — inside an isolated, per-session Mac instead of on the one shared host. The
/// macOS analogue of ``ContainerSpec``: `MacVMManager` owns its lifecycle and `MacVMEngine` clones
/// the golden base bundle, boots it, and `exec`s into it over SSH.
///
/// The guest is a **clone of a pre-provisioned golden base** (see `MacVMEngine+Base`): a real macOS
/// install seeded with the dev toolchain (Xcode, the iOS/other simulators, node/npm, python/pip),
/// an SSH-reachable `agent` account, and Nucleic's host public key. Everything expensive (the ~14 GB
/// restore-image install + the toolchain provisioning) happens once; each session gets a fast
/// copy-on-write clone of it, which is what lets several agents drive independent Macs at once.
public struct MacVMSpec: Sendable, Equatable {
/// One shared host directory made visible inside the guest (virtiofs). Unlike the Linux
/// container's identical-path bind mounts, a macOS guest mounts virtiofs shares under
/// `/Volumes/My Shared Files/<name>/`, so the guest path is derived from `name`, not `host`.
public struct Mount: Sendable, Equatable {
/// Host directory to share (the session's repo / worktree).
public let host: String
/// The share name the guest sees it under (the leaf of the guest mount path). Must be a
/// filesystem-safe single token; defaults are derived from the host path's last component.
public let name: String
public let readOnly: Bool
public init(host: String, name: String, readOnly: Bool) {
self.host = host
self.name = name
self.readOnly = readOnly
}
}
/// Stable logical VM name for this session (e.g. `nucleic-mac-<sessionID.short>`). Doubles as
/// the per-VM on-disk clone directory name and the network-lookup key.
public let name: String
/// Virtual CPU count for the guest. Clamped to the framework's allowed range at boot.
public let cpus: Int
/// Guest memory ceiling in GiB. Clamped to the framework's allowed range at boot.
public let memoryGiB: Int
/// Shared host directories surfaced to the guest over virtiofs (the repo/worktree).
public let mounts: [Mount]
/// Default working directory for `exec` **inside the guest**. Typically the guest mount path of
/// the session's repo (`/Volumes/My Shared Files/<name>`); `nil` → the agent account's home.
public let workdir: String?
/// Extra environment exported into every `exec`'d command inside the guest.
public let env: [String: String]
/// Stop the guest after this much inactivity (freeing its several GB of host RAM); the clone is
/// kept on disk so the next turn reboots it without re-installing.
public let idleTimeout: TimeInterval
/// The in-guest account Nucleic SSHes in as — the provisioned, key-authorized `agent` user.
public let sshUser: String
/// When true this VM is booted **computer-use-capable**: created on the main queue with a
/// host-side `VZVirtualMachineView` bound to it, so Nucleic captures its framebuffer and injects
/// keyboard/mouse HID entirely host-side (no in-guest agent, no TCC, no SIP). Set from
/// `SessionController.allowsMacVMComputer`. Exec-only VMs leave this false and boot headless on a
/// background queue.
public let computerUse: Bool
public init(
name: String,
cpus: Int = MacVMSettings.defaultVMCPUs,
memoryGiB: Int = MacVMSettings.defaultVMMemoryGiB,
mounts: [Mount] = [],
workdir: String? = nil,
env: [String: String] = [:],
idleTimeout: TimeInterval = TimeInterval(MacVMSettings.defaultIdleTimeoutSeconds),
sshUser: String = MacVMSettings.defaultSSHUser,
computerUse: Bool = false
) {
self.name = name
self.cpus = max(1, cpus)
self.memoryGiB = max(1, memoryGiB)
self.mounts = mounts
self.workdir = workdir
self.env = env
self.idleTimeout = idleTimeout
self.sshUser = sshUser
self.computerUse = computerUse
}
/// A copy of this spec under a different VM `name` — mirrors ``ContainerSpec/renamed(_:)`` so the
/// policy layer can run a VM under a distinct physical clone name while keeping a stable key.
public func renamed(_ newName: String) -> MacVMSpec {
MacVMSpec(
name: newName, cpus: cpus, memoryGiB: memoryGiB, mounts: mounts, workdir: workdir,
env: env, idleTimeout: idleTimeout, sshUser: sshUser, computerUse: computerUse)
}
}
/// Errors surfaced from the in-process macOS-VM engine.
public enum MacVMError: Error, Sendable, CustomStringConvertible {
/// The Virtualization macOS-guest runtime isn't usable here (not Apple silicon, or — surfaced at
/// VM start — no virtualization entitlement).
case unavailable(String)
/// An operation referenced a VM the engine isn't currently running.
case notRunning(String)
/// No golden base bundle exists and none could be produced (no restore image / no prebuilt path).
case baseImageMissing(String)
/// Installing macOS from the restore image failed. Carries the underlying message.
case installFailed(String)
/// Provisioning the golden base (dev toolchain, in-guest agent) failed.
case provisionFailed(String)
/// Cloning the base bundle for a per-session VM failed.
case cloneFailed(String)
/// The guest VM failed to create/start. Carries the underlying message.
case startFailed(String)
/// The in-guest vsock agent never became reachable within the boot deadline (the control plane is
/// vsock-only now — there is no network fallback).
case agentUnavailable(String)
/// Too many macOS guests are already running (host-resource / configured ceiling).
case concurrencyLimit(Int)
/// The golden base is exclusively in use (a build/provisioning pass or a Recovery session has it
/// booted writable), so a new session clone can't be cut from it right now. Transient — retry.
case baseBusy(String)
public var description: String {
switch self {
case let .unavailable(why): return "macOS VM runtime unavailable: \(why)"
case let .notRunning(name): return "macOS VM \"\(name)\" isn't running."
case let .baseImageMissing(why): return "No base macOS image available: \(why)"
case let .installFailed(msg): return "Failed to install macOS into the base VM: \(msg)"
case let .provisionFailed(msg): return "Failed to provision the base macOS VM: \(msg)"
case let .cloneFailed(msg): return "Failed to clone the base macOS VM: \(msg)"
case let .startFailed(msg): return "Failed to start the macOS VM: \(msg)"
case let .agentUnavailable(msg): return "The macOS guest agent never became reachable: \(msg)"
case let .concurrencyLimit(n): return "The macOS VM limit (\(n)) is already in use."
case let .baseBusy(why): return "The base macOS image is busy: \(why)"
}
}
}
/// A point-in-time reading for a running macOS guest, for the VM panel's resource monitor. Memory
/// is the configured ceiling and (best-effort) the guest's own reported free/used; CPU is a coarse
/// host-side estimate. Absent when the VM isn't running.
public struct MacVMResourceSample: Sendable, Equatable {
public let cpuPercent: Double
public let memoryUsedBytes: UInt64
public let memoryTotalBytes: UInt64
public init(cpuPercent: Double, memoryUsedBytes: UInt64, memoryTotalBytes: UInt64) {
self.cpuPercent = min(100, max(0, cpuPercent))
self.memoryUsedBytes = memoryUsedBytes
self.memoryTotalBytes = memoryTotalBytes
}
public var memoryPercent: Double {
memoryTotalBytes == 0 ? 0 : min(100, Double(memoryUsedBytes) / Double(memoryTotalBytes) * 100)
}
}
/// Progress of the one-time base-image acquisition (restore-image download, macOS install, toolchain
/// provisioning), surfaced to the VM settings panel. `nil` once the golden base exists — the steady
/// state. Mirrors ``ContainerDownloadProgress`` in spirit but tracks the much longer install phases.
public struct MacVMBaseProgress: Sendable, Equatable {
public enum Phase: String, Sendable, Equatable {
case downloadingRestoreImage
case installing
/// macOS-27 declarative first boot: the guest account + auto-login + Remote Login are
/// created unattended (`VZMacGuestProvisioningOptions`), then Nucleic's key is authorized.
case firstBootSetup
case provisioning
/// Baking the native `NucleicVMAgent.app` (+ LaunchAgent + TCC grants) into the base.
case installingAgent
/// Reading back readiness + shutting the guest down cleanly so the base can be cloned.
case finalizing
case ready
}
public let phase: Phase
/// 0…1 within the current phase when known (install/download report a fraction); `nil` for the
/// indeterminate provisioning phase.
public let fraction: Double?
public init(phase: Phase, fraction: Double? = nil) {
self.phase = phase
self.fraction = fraction
}
public var label: String {
switch phase {
case .downloadingRestoreImage: return "Downloading macOS restore image"
case .installing: return "Installing macOS into the base VM"
case .firstBootSetup: return "Setting up the guest account (first boot, unattended)"
case .provisioning: return "Installing the dev toolchain (Xcode, simulators, node, python)"
case .installingAgent: return "Installing the in-guest agent + computer-use permissions"
case .finalizing: return "Finalizing the base image"
case .ready: return "Base macOS image ready"
}
}
}
/// Provisioning state of the golden base bundle, read from its `bundle.json`. Drives the VM settings
/// panel. Computer use runs **host-side** (the guest's virtual display + HID), so it needs no
/// provisioning at all — `computerUseReady` is just "installed". The optional in-guest **semantic AX
/// agent** (`axAgentReady`) is the one thing that still needs SIP (docs/MACOS_VM.md §12.5). Tolerant
/// of older bases missing the newer keys.
public struct MacVMBaseStatus: Sendable, Equatable {
/// A clean macOS was installed into the bundle.
public var installed: Bool
/// The key-authorized `agent` account exists (macOS-27 declarative first boot, or the manual
/// Setup-Assistant path). Nucleic can SSH in — the precondition for the toolchain/agent pass.
public var accountProvisioned: Bool
/// The dev toolchain provisioning ran to completion (Xcode CLT, Homebrew, node/python, the
/// `/etc/zshenv` PATH) — the base is exec-ready.
public var provisioned: Bool
/// `NucleicVMAgent.app` + its LaunchAgent are installed in the guest (the optional AX add-on).
public var agentInstalled: Bool
/// SIP is disabled in the guest (required to write the AX agent's TCC grants).
public var sipDisabled: Bool
/// The AX agent is installed AND its TCC grants are in place — semantic `ax_*` control will work.
public var axAgentReady: Bool
/// Installed guest macOS version / build, for display + host-compatibility checks.
public var osVersion: String?
public var buildVersion: String?
/// Computer use (screenshot + click/type/scroll) works host-side on ANY installed base — no
/// provisioning, no SIP. So "computer-use-ready" is simply "installed".
public var computerUseReady: Bool { installed }
public init(
installed: Bool = false, accountProvisioned: Bool = false, provisioned: Bool = false,
agentInstalled: Bool = false, sipDisabled: Bool = false, axAgentReady: Bool = false,
osVersion: String? = nil, buildVersion: String? = nil
) {
self.installed = installed
self.accountProvisioned = accountProvisioned
self.provisioned = provisioned
self.agentInstalled = agentInstalled
self.sipDisabled = sipDisabled
self.axAgentReady = axAgentReady
self.osVersion = osVersion
self.buildVersion = buildVersion
}
/// Parse from a decoded `bundle.json` dictionary, defaulting any keys an older base predates.
public init(json: [String: Any]) {
self.init(
installed: json["installed"] as? Bool ?? false,
accountProvisioned: json["accountProvisioned"] as? Bool ?? false,
provisioned: json["provisioned"] as? Bool ?? false,
agentInstalled: json["agentInstalled"] as? Bool ?? false,
sipDisabled: json["sipDisabled"] as? Bool ?? false,
// Accept the legacy `computerUseReady` key (its old meaning was the agent path).
axAgentReady: (json["axAgentReady"] as? Bool) ?? (json["computerUseReady"] as? Bool) ?? false,
osVersion: json["osVersion"] as? String,
buildVersion: json["buildVersion"] as? String)
}
/// Serialize back to the `bundle.json` dictionary shape (JSON-safe values only).
public var dictionary: [String: Any] {
var d: [String: Any] = [
"installed": installed, "accountProvisioned": accountProvisioned,
"provisioned": provisioned, "agentInstalled": agentInstalled,
"sipDisabled": sipDisabled, "axAgentReady": axAgentReady,
]
if let osVersion { d["osVersion"] = osVersion }
if let buildVersion { d["buildVersion"] = buildVersion }
return d
}
/// A one-line human summary for the settings panel.
public var summary: String {
if !installed { return "No base image built yet." }
if !accountProvisioned { return "Installed — account setup incomplete." }
if axAgentReady { return "Ready — builds + computer use + semantic AX." }
if provisioned { return "Ready — builds + computer use." }
return "Installed — computer use ready; toolchain provisioning incomplete."
}
}