268 lines
14 KiB
Swift
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."
|
|
}
|
|
}
|