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

834 lines
44 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
#if arch(arm64)
import Virtualization
#endif
/// In-process **macOS-guest VM runtime** built directly on Apple's `Virtualization` framework — the
/// Mac-native counterpart to ``ContainerEngine``. Where the container engine boots Linux guests via
/// the `containerization` framework, this actor boots real macOS guests: it owns a golden **base
/// bundle** (a provisioned macOS install with the dev toolchain + the native in-guest `agent`),
/// clones it copy-on-write per session, boots each clone on its own serial `VZVirtualMachine` queue,
/// and `exec`s commands inside **over vsock** (the native agent's streaming exec) so an agent can
/// build/test Mac targets in isolation — no SSH, no NAT, no network in the host↔guest control plane.
///
/// **Daemonless ⇒ ephemeral**, exactly like the container engine: no VM survives the app process, so
/// the `live` registry starts empty each launch and `reconcile` is on-disk GC. The per-session clone
/// (the CoW `Disk.img` + its aux storage) persists on disk, so an idle-stopped or app-restarted VM
/// reboots fast without re-installing macOS.
///
/// Lifecycle *policy* (busy ref-counting, idle timers, the concurrent-VM ceiling, per-session naming)
/// lives one layer up in ``MacVMManager``; this engine is the mechanism it drives. The only
/// entitlement required is `com.apple.security.virtualization` (already carried by the app and the
/// spike) — macOS guests use it too, and NAT networking needs no extra, restricted entitlement.
public actor MacVMEngine {
/// One live macOS guest the engine is currently running.
struct LiveVM {
#if arch(arm64)
let instance: MacVMInstance
/// The native in-guest agent channel for this VM (docs/MACOS_VM_NATIVE_AGENT.md), probed
/// lazily by ``MacVMEngine/agentClient(name:)`` and cached here for the VM's lifetime.
var agent: MacVMAgentState = .unprobed
/// The Linux **a11y (AT-SPI) semantic agent** channel (docs/LINUX_VM_SEMANTIC_AGENT.md) — a
/// separate vsock port from the C `ping`/`exec` agent above, probed lazily by
/// ``MacVMEngine/linuxA11yClient(name:)``. Only meaningful for Linux guests.
var a11yAgent: MacVMAgentState = .unprobed
#endif
/// Which guest OS this VM is running — selects the exec shell and gates macOS-only computer-use
/// paths (the semantic AX ops have no Linux equivalent).
let os: GuestOS
/// The clone bundle this VM booted from (its writable disk + aux storage live here).
let bundle: MacVMBundle
/// Locally-administered MAC assigned to this clone, used only to find its NAT DHCP lease for
/// the settings-panel IP display (the control plane is vsock).
let macAddress: String
/// The guest account the native agent runs as (created by declarative provisioning).
let sshUser: String
/// The guest's NAT IP for display, once discovered — best-effort and not on any control path.
var ipAddress: String?
/// Configured memory ceiling in bytes, for the resource sample.
let memoryCeilingBytes: UInt64
/// When the VM was started (for a coarse CPU estimate / uptime).
let startedAt: Date
/// True when this VM was booted computer-use-capable (main queue + a host-side surface bound),
/// so the computer-use dispatch prefers the SIP-free host-IO path over SSH/agent.
var computerUse: Bool = false
}
/// On-disk root for engine artifacts: the cached restore image, the agent-account credential, the
/// golden base bundle, and per-session clone bundles.
let storageRoot: URL
/// Live VMs keyed by logical name. Empty at launch (nothing survives the app process).
var live: [String: LiveVM] = [:]
/// In-flight boots keyed by name, so concurrent `ensureRunning` for the SAME name join the one
/// boot instead of racing into a second `VZVirtualMachine` on the same writable disk. Also counted
/// toward the concurrency ceiling so a burst of distinct-name boots can't overshoot it.
private var boots: [String: Task<(name: String, ipAddress: String), Error>] = [:]
/// Last spec seen per name, so `restart` (which only has a name) can recreate from disk.
var lastSpec: [String: MacVMSpec] = [:]
#if arch(arm64)
/// Probe state of a live VM's native in-guest agent (the vsock channel). `unavailable` is
/// sticky for the VM's lifetime — a base image without the agent will never grow one mid-boot,
/// so re-probing every action would just pay the connect timeout repeatedly. A transport
/// failure on a previously `available` channel resets to `unprobed` instead (the LaunchAgent's
/// `KeepAlive` restarts a crashed agent, so one reconnect attempt is worth it).
enum MacVMAgentState {
case unprobed
case unavailable
case available(MacVMAgentClient)
}
#endif
/// Held while at least one VM is live, to keep the host app from being App-Napped / auto-terminated
/// out from under a running guest (mirrors ``ContainerEngine``'s assertion; the runtime is
/// daemonless so the app process bounds every VM's life). `nil` when nothing is live.
private var backgroundActivity: (any NSObjectProtocol)?
/// Progress of the one-time base-image build (download / install / provision), surfaced to the VM
/// settings panel. `nil` in the steady state (base already built, nothing installing). Mutated only
/// on the actor; read via ``currentBaseProgress()``.
var baseProgress: MacVMBaseProgress?
/// True while a base-image build is in flight, so a second ``buildBaseImage`` call can't remove the
/// shared `base.building` temp bundle out from under the running installer. Actor-guarded.
var baseBuilding = false
/// True while the base is booted writable for a **Recovery** session (`csrutil disable`), set via
/// ``beginBaseRecovery()``/``endBaseRecovery()``. Both flags block cutting a fresh session clone
/// from the base, since cloning reads the base disk that would then be mid-write.
var baseRecoveryActive = false
/// True while the base is booted writable to **inject user apps** into its `/Applications`
/// (``installAppsIntoBase(appPaths:)`` — the "add an app without a full rebuild" path). Like the
/// build/Recovery flags it must block cutting a fresh clone while the base disk is mid-write.
var baseAppInstalling = false
/// Whether the operator asked to watch the base-build VM in a diagnostic monitor window. Applied
/// when the provisioning surface attaches, and toggled live via ``setBaseProvisionObserver(visible:)``.
var baseObserverRequested = false
/// The base disk is being written (a build/provisioning pass, a Recovery session, or an app
/// injection), so it must not be read for a copy-on-write clone right now (a torn read would
/// corrupt the clone).
var baseIsBusy: Bool { baseBuilding || baseRecoveryActive || baseAppInstalling }
/// Host-side computer-use surface (VZVirtualMachineView framebuffer capture + HID injection),
/// injected by the app layer at startup. `nil` in headless/spike contexts. Bound to computer-use
/// VMs (main-queue) for the agent, and to the base-build VM to drive the HID provisioning bootstrap.
var surfaceHost: (any MacVMSurfaceHost)?
/// Wire the app-layer host-IO surface (called once at startup, like the manager injection).
public func setSurfaceHost(_ host: any MacVMSurfaceHost) { surfaceHost = host }
public init(storageRoot: URL? = nil) {
self.storageRoot = storageRoot ?? Self.defaultStorageRoot()
}
// MARK: - Capability
/// Whether the in-process macOS-guest runtime can run on this machine. `true` iff
/// ``unsupportedReason`` is `nil`.
public static var isSupported: Bool { unsupportedReason == nil }
/// A specific, actionable reason the macOS-VM runtime is unavailable, or `nil` when supported.
/// The hard gate is the CPU architecture: Apple's Mac-guest APIs (`VZMac*`) exist only on Apple
/// silicon — an Intel host cannot create a macOS guest at all. The virtualization *entitlement* is
/// undetectable here (a missing one surfaces as a `startFailed` when the first VM boots); the
/// deployment floor (macOS 26) is enforced by the build itself.
public static var unsupportedReason: String? {
#if arch(arm64)
return nil
#else
return "Running macOS guests requires Apple silicon."
#endif
}
static func defaultStorageRoot() -> URL {
let base = (try? FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true))
?? URL(fileURLWithPath: NSTemporaryDirectory())
return base.appendingPathComponent("Nucleic/macvms", isDirectory: true)
}
// MARK: - On-disk layout
/// Directory holding per-session clone bundles.
var instancesDir: URL { storageRoot.appendingPathComponent("instances", isDirectory: true) }
/// The golden base bundle the engine builds when the user hasn't supplied a prebuilt one.
var builtBaseDir: URL { storageRoot.appendingPathComponent("base", isDirectory: true) }
/// The golden **Linux** base bundle (kernel + initrd + provisioned ext4 rootfs).
var builtLinuxBaseDir: URL { storageRoot.appendingPathComponent("linux-base", isDirectory: true) }
/// Cached Linux boot artifacts (downloaded kernel `Image`, initrd, Ubuntu Base rootfs tarball),
/// version-keyed by source filename like the restore-image cache.
var linuxArtifactsDir: URL { storageRoot.appendingPathComponent("linux-artifacts", isDirectory: true) }
/// Cached restore images (`.ipsw`).
var restoreDir: URL { storageRoot.appendingPathComponent("restore", isDirectory: true) }
/// Small persisted credentials the base build needs (currently just the generated `agent`-account
/// password used to prime sudo during provisioning). No SSH keys — the control plane is vsock.
var credentialsDir: URL { storageRoot.appendingPathComponent("credentials", isDirectory: true) }
/// Per-session clone bundle for `name`.
func instanceBundle(for name: String) -> MacVMBundle {
MacVMBundle(root: instancesDir.appendingPathComponent(name, isDirectory: true))
}
// MARK: - Base-image progress
/// The one-time base-image build currently in flight, for the VM settings panel. `nil` once the
/// golden base exists. A trivial property read (no I/O).
public func currentBaseProgress() -> MacVMBaseProgress? { baseProgress }
// MARK: - Lifecycle
/// Ensure the named macOS VM is running and return its name plus the guest's SSH-reachable IP.
/// Idempotent: a VM already live is returned as-is; a boot already in flight for this name is
/// *joined* (not duplicated); otherwise the golden base is cloned copy-on-write, booted on its own
/// queue, and awaited until SSH answers.
///
/// The whole dedup + concurrency-ceiling check runs synchronously on this actor before the first
/// `await`, so it is atomic: two concurrent calls for the same name can't double-boot the same
/// writable disk, and a burst of distinct-name calls can't blow past ``MacVMSettings/maxConcurrentVMs``.
@discardableResult
public func ensureRunning(_ spec: MacVMSpec) async throws -> (name: String, ipAddress: String) {
lastSpec[spec.name] = spec
if let existing = live[spec.name], let ip = existing.ipAddress {
return (spec.name, ip)
}
// Join an in-flight boot for the same name rather than starting a second one.
if let inFlight = boots[spec.name] {
return try await inFlight.value
}
guard Self.isSupported else {
throw MacVMError.unavailable(Self.unsupportedReason ?? "unsupported")
}
// Atomic ceiling check (running + in-flight boots), on the actor, before any await. macOS caps
// simultaneous macOS guests; overshooting would fail an opaque `VZVirtualMachine.start`.
let limit = MacVMSettings.maxConcurrentVMs
if live.count + boots.count >= limit {
throw MacVMError.concurrencyLimit(limit)
}
// Reserve the slot by publishing the boot task synchronously, then await it. The task inherits
// this actor's isolation, so `performBoot` runs serialized on the actor.
let task = Task { try await self.performBoot(spec) }
boots[spec.name] = task
defer { boots[spec.name] = nil }
return try await task.value
}
/// The actual boot: clone the golden base, build the VZ config, start the guest on its own queue,
/// wire the guest-stop hook, and wait until SSH answers. Only reached via ``ensureRunning``'s
/// dedup/ceiling gate, so it never double-boots a name or overshoots the ceiling.
private func performBoot(_ spec: MacVMSpec) async throws -> (name: String, ipAddress: String) {
#if arch(arm64)
// Resolve (building if necessary) the golden base for this guest OS, then clone it.
let base = try await ensureBaseBundle(for: spec.os)
let bundle = instanceBundle(for: spec.name)
let mac: String
if bundle.isComplete(for: spec.os) {
// Reuse an existing clone (idle-restart / relaunch): its identity + MAC persist on disk.
// This touches only the clone's own disk, so it's safe even while the base is busy.
mac = (try? String(contentsOf: bundle.macAddressURL, encoding: .utf8))?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? Self.randomMAC()
} else {
// Cutting a fresh clone READS the base disk — refuse while it's booted writable (a build/
// provisioning pass or a Recovery session), so the clone can't capture a torn state.
guard !baseIsBusy else {
throw MacVMError.baseBusy(
"the base image is being provisioned or edited — retry once it's done")
}
mac = try cloneBase(base, to: bundle, os: spec.os)
}
let config: VZVirtualMachineConfiguration
do {
config = try Self.makeConfiguration(
bundle: bundle, os: spec.os, cpus: spec.cpus, memoryGiB: spec.memoryGiB, mac: mac,
mounts: spec.mounts)
} catch {
throw MacVMError.startFailed("configuration: \(error)")
}
let instance = MacVMInstance(
configuration: config, label: spec.name, mainQueue: spec.computerUse)
do {
try await instance.start()
} catch {
throw MacVMError.startFailed(String(describing: error))
}
let memoryBytes = UInt64(spec.memoryGiB) * 1024 * 1024 * 1024
live[spec.name] = LiveVM(
instance: instance, os: spec.os, bundle: bundle, macAddress: mac, sshUser: spec.sshUser,
ipAddress: nil, memoryCeilingBytes: memoryBytes, startedAt: Date(),
computerUse: spec.computerUse)
syncBackgroundActivity()
// Bind the host-side computer-use surface (framebuffer capture + HID) to this VM, so the
// agent can see/drive it with no in-guest software, no TCC, no SIP. Attaching right after
// start (before SSH is even up) lets the surface render the boot/login screen too.
if spec.computerUse, let host = surfaceHost {
await host.attach(
name: spec.name, virtualMachine: UncheckedSendableBox(value: instance.vm as AnyObject))
}
// A guest that powers itself off (in-guest `shutdown`, panic, OOM) must not linger in `live`
// — that would leak a concurrency slot and pin the app-activity assertion. Clear it when the
// VZ delegate reports the stop. The hook hops back onto this actor.
let name = spec.name
instance.setOnStop { [weak self] in
Task { await self?.handleGuestStopped(name) }
}
// Wait until the in-guest agent answers over vsock — the readiness gate. First boot of a fresh
// clone can take a couple of minutes (login window + launchd + the agent's LaunchAgent); reuse
// is quicker.
do {
try await awaitAgentReady(name: spec.name)
// Best-effort NAT IP for the settings-panel display only; never gates readiness.
let ip = Self.leaseIP(forMAC: mac) ?? ""
live[spec.name]?.ipAddress = ip.isEmpty ? nil : ip
return (spec.name, ip)
} catch {
// The VM came up but the agent never answered — tear it back down so a retry starts clean.
await instance.stop()
live[spec.name] = nil
syncBackgroundActivity()
throw error
}
#else
throw MacVMError.unavailable(Self.unsupportedReason ?? "requires Apple silicon")
#endif
}
/// Drop a VM the guest stopped on its own (VZ delegate → ``MacVMInstance/setOnStop(_:)``), so a
/// crashed/powered-off guest doesn't keep consuming a concurrency slot or hold the app-activity
/// assertion. Idempotent: a no-op if the engine already tore it down.
private func handleGuestStopped(_ name: String) {
guard let entry = live[name] else { return }
live[name] = nil
syncBackgroundActivity()
if entry.computerUse { Task { await surfaceHost?.detach(name: name) } }
#if arch(arm64)
if case .available(let client) = entry.agent {
Task { await client.close() }
}
#endif
}
/// Run `argv` inside the named guest **over vsock** (the in-guest agent's streaming `exec` op),
/// returning a ``ProcessHandle`` whose stdio is the remote command's stdio. The agent's mac-VM
/// tool treats it identically to a host or containerized process — see ``MacVMExecChannel``.
public func exec(
name: String, workdir: String?, env: [String: String], argv: [String]
) async throws -> any ProcessHandle {
let body = argv.map(Self.shQuote).joined(separator: " ")
return try await execHandle(name: name, workdir: workdir, env: env, remoteBody: body)
}
/// Convenience one-shot used by the `mac_vm_exec` tool: run a shell `command` inside the guest and
/// return its exit code plus captured stdout/stderr — the macOS analogue of `runOnHost`.
public func run(
name: String, command: String, workdir: String?, env: [String: String] = [:]
) async throws -> (exitCode: Int32, stdout: String, stderr: String) {
let handle = try await execHandle(name: name, workdir: workdir, env: env, remoteBody: command)
// Drain both streams concurrently, then await exit — a big build must not deadlock on a full
// stderr pipe while we read stdout (or vice versa).
async let out = Self.collect(handle.stdoutLines)
async let err = Self.collect(handle.stderrLines)
let stdout = await out
let stderr = await err
let code = await handle.wait()
return (code, stdout, stderr)
}
/// Run a command in the guest over SSH and return its stdout up to `maxBytes`, plus whether it was
/// truncated — for a base64 payload (a screenshot) that ``run``'s 64 KB cap would mangle but that
/// still needs a ceiling so a pathological capture can't blow up host memory / the model context.
/// A truncated base64 is unusable, so the caller discards it. Ignores exit code; stderr discarded.
func rawStdout(
name: String, command: String, maxBytes: Int = 8 * 1024 * 1024
) async throws -> (text: String, truncated: Bool) {
let handle = try await execHandle(name: name, workdir: nil, env: [:], remoteBody: command)
let result = await Self.collectBounded(handle.stdoutLines, cap: maxBytes)
_ = await handle.wait()
return result
}
/// Drain a line stream into one string up to `cap` bytes, reporting whether the cap was hit. Unlike
/// ``collect`` this appends NO truncation marker (which would corrupt a base64 payload) — the caller
/// decides what a truncated result means.
private static func collectBounded(
_ stream: AsyncThrowingStream<Data, Error>, cap: Int
) async -> (text: String, truncated: Bool) {
var lines: [String] = []
var bytes = 0
var truncated = false
do {
for try await line in stream {
if bytes < cap {
lines.append(String(decoding: line, as: UTF8.self))
bytes += line.count + 1
} else {
truncated = true
}
}
} catch { /* stream ended on error — return what we have */ }
return (lines.joined(separator: "\n"), truncated)
}
/// Stop the guest (freeing its host RAM) but keep the on-disk clone bundle so the next
/// `ensureRunning` reboots it without re-installing. Drops the registry entry; best-effort.
public func stop(name: String) async {
guard let entry = live[name] else { return }
if entry.computerUse { await surfaceHost?.detach(name: name) }
#if arch(arm64)
if case .available(let client) = entry.agent { await client.close() }
await entry.instance.stop()
#endif
live[name] = nil
syncBackgroundActivity()
}
/// Stop then re-boot the guest in place (reuses the clone; reclaims the host RAM a long-running VM
/// holds). No-op if the engine never saw this VM.
public func restart(name: String) async {
guard let spec = lastSpec[name] else { await stop(name: name); return }
await stop(name: name)
_ = try? await ensureRunning(spec)
}
/// Stop the guest and delete its on-disk clone bundle. Returns `true` once it's gone (including
/// when it never existed). Best-effort.
@discardableResult
public func remove(name: String) async -> Bool {
await stop(name: name)
lastSpec[name] = nil
let root = instanceBundle(for: name).root
try? FileManager.default.removeItem(at: root)
return !FileManager.default.fileExists(atPath: root.path)
}
/// True if the named VM is currently running in this process.
public func isRunning(_ name: String) async -> Bool { live[name] != nil }
/// True if the VM is running, or has a persisted clone bundle on disk (stopped but re-bootable).
/// OS-agnostic: a clone directory holds either a macOS or a Linux guest, so accept either shape.
public func vmExists(_ name: String) async -> Bool {
if live[name] != nil { return true }
let bundle = instanceBundle(for: name)
return bundle.isComplete(for: .macOS) || bundle.isComplete(for: .linux)
}
/// Names of all macOS VMs currently running in this process.
public func list() async -> [String] { Array(live.keys) }
/// Running VMs with their discovered IPs, for the settings panel and the Usage panel. Carries
/// each guest's OS and configured core count (from the last spec it booted with) so the Usage
/// panel can label macOS vs. Linux and re-scale its CPU reading against the host.
public func runningVMs() async -> [MacVMEntry] {
live.map { name, vm in
MacVMEntry(
name: name, ipAddress: vm.ipAddress,
cpus: lastSpec[name]?.cpus ?? MacVMSettings.defaultVMCPUs, os: vm.os)
}
}
/// Number of macOS guests currently running — the manager consults this against the configured
/// ceiling before booting another (macOS caps simultaneous macOS guests).
public func runningCount() async -> Int { live.count }
/// Best-effort resource sample for the VM panel. The Virtualization framework exposes no CPU or
/// memory figures for a macOS guest, so we probe from *inside* it over SSH: `vm_stat` for memory
/// (instantaneous and reliable) and a two-sample `top` for CPU (the first `top` sample is
/// meaningless, hence `-l 2`). Both ride one round-trip. Memory total is the configured ceiling
/// (= `hw.memsize` in the guest), so it needs no probe. Returns a ceiling-only sample (used/CPU
/// zero) when the guest isn't reachable yet, and `nil` when the VM isn't running at all.
///
/// This is called off the Control panel's critical path (a background refresh that caches the
/// result), never inline — the `top -l 2` read alone sleeps ~1 s in the guest.
public func sampleResourceUsage(name: String) async -> MacVMResourceSample? {
guard let entry = live[name] else { return nil }
let total = entry.memoryCeilingBytes
// No IP yet → still booting; skip the SSH probe and report the ceiling only.
guard entry.ipAddress != nil else {
return MacVMResourceSample(cpuPercent: 0, memoryUsedBytes: 0, memoryTotalBytes: total)
}
let probe = "vm_stat; echo __CPU__; top -l 2 -n 0 | awk '/CPU usage/{c=$0} END{print c}'"
let stdout = (try? await run(name: name, command: probe, workdir: nil))?.stdout ?? ""
return Self.parseResourceProbe(stdout, memoryTotal: total)
}
/// Parse the guest resource probe (`vm_stat` + a `top` "CPU usage" line) into a sample. Used
/// memory is `(active + wired + compressed) × page-size`; CPU is `100 idle%` from `top`. Pure
/// and total-driven so it's unit-testable without a live guest — a blank/garbled probe yields a
/// ceiling-only sample (zeros) rather than throwing.
static func parseResourceProbe(_ output: String, memoryTotal: UInt64) -> MacVMResourceSample {
var pageSize: UInt64 = 4096
var active: UInt64 = 0, wired: UInt64 = 0, compressed: UInt64 = 0
var cpuPercent = 0.0
for raw in output.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
// `vm_stat` header carries the page size, e.g. "… (page size of 16384 bytes)".
if line.contains("page size of"), let n = Self.trailingUInt(in: line) { pageSize = n }
else if line.hasPrefix("Pages active:"), let n = Self.trailingUInt(in: line) { active = n }
else if line.hasPrefix("Pages wired down:"), let n = Self.trailingUInt(in: line) { wired = n }
else if line.hasPrefix("Pages occupied by compressor:"),
let n = Self.trailingUInt(in: line) { compressed = n }
else if line.contains("CPU usage"), let idle = Self.idlePercent(in: line) {
cpuPercent = max(0, 100 - idle)
}
}
let used = (active &+ wired &+ compressed) &* pageSize
return MacVMResourceSample(
cpuPercent: cpuPercent, memoryUsedBytes: used, memoryTotalBytes: memoryTotal)
}
/// The integer formed by the digits at the end of a `vm_stat` line (e.g. "Pages active: 123456."
/// → 123456, "…page size of 16384 bytes)" picks up 16384 since it's the only run of digits).
private static func trailingUInt(in line: String) -> UInt64? {
let digits = String(line.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) })
return UInt64(digits)
}
/// The idle percentage from a `top` "CPU usage: 4.76% user, 9.52% sys, 85.71% idle" line.
private static func idlePercent(in line: String) -> Double? {
guard let range = line.range(of: "% idle") else { return nil }
let number = line[..<range.lowerBound]
.reversed().prefix { $0.isNumber || $0 == "." }.reversed()
return Double(String(number))
}
// MARK: - Host-side computer-use surface (SIP-free framebuffer capture + HID injection)
/// Whether `name` has a bound host-side surface — a computer-use VM booted with the app's surface
/// injected. When true, the computer-use dispatch drives it host-side (no in-guest agent/TCC/SIP).
func hasComputerSurface(_ name: String) -> Bool {
surfaceHost != nil && (live[name]?.computerUse ?? false)
}
/// Host-side framebuffer capture (JPEG base64, 1920×1200) for a computer-use VM, or `nil`.
func captureSurfaceBase64(name: String) async -> String? {
guard hasComputerSurface(name), let data = await surfaceHost?.capture(name: name) else {
return nil
}
return data.base64EncodedString()
}
/// Inject one host-side input action into a computer-use VM (no-op without a surface).
func sendSurfaceInput(name: String, _ input: MacVMSurfaceInput) async {
guard hasComputerSurface(name) else { return }
await surfaceHost?.send(name: name, input)
}
/// Host-tracked pointer position for a computer-use VM (we synthesize the moves).
func surfaceCursor(name: String) async -> Point? {
guard hasComputerSurface(name) else { return nil }
return await surfaceHost?.cursorPosition(name: name)
}
/// Show/hide the operator-assist viewer for a computer-use VM: bring its live, interactive
/// `VZVirtualMachineView` on-screen so the user can drive the guest by hand (`mac_vm_request_operator`),
/// or send it back off-screen. No-op without a computer-use surface for `name`.
func presentOperatorAssist(name: String, visible: Bool) async {
guard hasComputerSurface(name) else { return }
await surfaceHost?.presentOperatorAssist(name: name, visible: visible)
}
/// Current GUI framebuffer of the named guest as a base64 JPEG, for the live **VM monitor** panel.
/// Reuses the computer-use `screenshot` path — the native in-guest agent's ScreenCaptureKit capture
/// when present, else SSH `screencapture` in the console user's Aqua session — but with no lifecycle
/// effect, and returns `nil` (rather than throwing) when the VM isn't running or the capture is
/// empty/unavailable, so a passive monitor can poll it cheaply. Like computer-use, it needs a base
/// image provisioned for screen capture (Screen Recording TCC + auto-login); an unprovisioned guest
/// yields `nil` and the panel shows a "no signal" placeholder.
public func captureScreen(name: String) async -> String? {
guard live[name] != nil else { return nil }
return try? await performComputerAction(
name: name, action: "screenshot", x: nil, y: nil, text: nil,
scrollDirection: nil, scrollAmount: nil, durationMs: nil
).imageBase64
}
/// On app launch, reconcile on-disk clone bundles: keep only the active sessions' clones and drop
/// the rest (daemonless — no live VM survives the process, so this is pure on-disk GC).
public func reconcileDisk(keepNames: Set<String>) async {
let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(
at: instancesDir, includingPropertiesForKeys: nil) else { return }
for dir in entries where !keepNames.contains(dir.lastPathComponent) {
try? fm.removeItem(at: dir)
}
}
// MARK: - Background activity
private func syncBackgroundActivity() {
if !live.isEmpty, backgroundActivity == nil {
backgroundActivity = ProcessInfo.processInfo.beginActivity(
options: [
.userInitiatedAllowingIdleSystemSleep, .suddenTerminationDisabled,
.automaticTerminationDisabled,
],
reason: "Nucleic macOS VM running")
} else if live.isEmpty, let activity = backgroundActivity {
ProcessInfo.processInfo.endActivity(activity)
backgroundActivity = nil
}
}
// MARK: - vsock exec plumbing
/// Build a `ProcessHandle` for a command run inside the guest **over vsock** — a dedicated
/// connection to the in-guest agent's streaming `exec` op (docs/MACOS_VM_NATIVE_AGENT.md §4.4). This
/// replaced the `ssh`-over-NAT handle: no IP discovery, no host-key churn, no Remote Login, no
/// network permission surface. The composed script is identical to what sshd used to run, so the
/// guest's toolchain PATH (via `/etc/zshenv`) resolves the same way.
///
/// Each exec gets its OWN vsock connection (the agent listener is one thread per connection), so a
/// multi-minute build never blocks the control channel and concurrent execs don't interfere.
private func execHandle(
name: String, workdir: String?, env: [String: String], remoteBody: String
) async throws -> any ProcessHandle {
#if arch(arm64)
return try await openExecChannel(name: name, workdir: workdir, env: env, remoteBody: remoteBody)
#else
throw MacVMError.unavailable(Self.unsupportedReason ?? "requires Apple silicon")
#endif
}
#if arch(arm64)
/// Open a dedicated vsock exec channel to the named guest and dispatch `remoteBody` (wrapped with
/// env/workdir by ``remoteScript``). Returns the concrete ``MacVMExecChannel`` so bulk callers can
/// stream binary stdin (``MacVMExecChannel/writeStdinRaw(_:deadline:)``); ``execHandle`` erases it
/// to `any ProcessHandle` for the ordinary exec path.
func openExecChannel(
name: String, workdir: String?, env: [String: String], remoteBody: String
) async throws -> MacVMExecChannel {
guard let entry = live[name] else { throw MacVMError.notRunning(name) }
return try await openExecChannel(
instance: entry.instance, label: name, workdir: workdir, env: env, remoteBody: remoteBody)
}
/// Open an exec channel directly on a ``MacVMInstance`` — for VMs the engine boots OUTSIDE the
/// `live` registry (the base app-injection boot), which have no name to look up. `label` is used
/// only in the error message.
func openExecChannel(
instance: MacVMInstance, label: String, workdir: String?, env: [String: String],
remoteBody: String
) async throws -> MacVMExecChannel {
let script = Self.remoteScript(workdir: workdir, env: env, body: remoteBody)
let box: UncheckedSendableBox<VZVirtioSocketConnection>
do {
box = try await withAgentTimeout(seconds: 10) {
try await instance.connectAgent(port: MacVMAgentWire.port)
}
} catch {
throw MacVMError.agentUnavailable(
"could not open an exec channel to \"\(label)\": \(error)")
}
return MacVMExecChannel(connection: box, command: script)
}
#endif
/// Compose the remote shell script: export the extra env, `cd` into the working directory, then
/// run the body. The guest's toolchain PATH is made available to non-login shells by provisioning
/// (it appends to `/etc/zshenv`), so `xcodebuild`/`brew`/`node` resolve without a login shell.
static func remoteScript(workdir: String?, env: [String: String], body: String) -> String {
var s = ""
for (k, v) in env.sorted(by: { $0.key < $1.key }) {
s += "export \(k)=\(shQuote(v)); "
}
if let workdir, !workdir.isEmpty {
s += "cd \(shQuote(workdir)) || exit 1; "
}
s += body
return s
}
/// POSIX single-quote a string for safe interpolation into a shell command.
static func shQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
/// Drain an async line stream into one newline-joined string, capped so a runaway build log
/// (`xcodebuild` output is routinely multi-MB) can't blow up the tool result the agent reads back
/// or balloon host memory — mirrors the host path's `runOnHost` capping. Keeps consuming past the
/// cap (so the ssh pipe never blocks) but drops the overflow, appending a truncation marker.
private static func collect(
_ stream: AsyncThrowingStream<Data, Error>, cap: Int = 64 * 1024
) async -> String {
var lines: [String] = []
var bytes = 0
var truncated = false
do {
for try await line in stream {
if bytes < cap {
lines.append(String(decoding: line, as: UTF8.self))
bytes += line.count + 1
} else {
truncated = true
}
}
} catch { /* stream ended on error — return what we have */ }
var out = lines.joined(separator: "\n")
if truncated { out += "\n…[output truncated at \(cap / 1024) KB]" }
return out
}
// MARK: - Guest readiness
/// Wait until the in-guest agent answers a vsock `ping`, bounded. This IS the boot-readiness gate:
/// the host↔guest control plane is vsock-only, so "the agent answers" is exactly "the guest is
/// usable for exec + computer-use". No DHCP lease, no sshd, no IP on the critical path.
///
/// On success the live `ping` channel is cached as the VM's control client, so the first
/// computer-use op doesn't pay a second probe. Non-sticky: a failed probe just retries (unlike the
/// lazy ``agentClient(name:)``, whose `.unavailable` is sticky for the VM's lifetime).
func awaitAgentReady(name: String) async throws {
#if arch(arm64)
guard let instance = live[name]?.instance else { throw MacVMError.notRunning(name) }
let deadline = Date().addingTimeInterval(300) // first boot: login window + launchd + agent
while Date() < deadline {
if let box = try? await withAgentTimeout(seconds: 5, {
try await instance.connectAgent(port: MacVMAgentWire.port)
}) {
let client = MacVMAgentClient(connection: box)
if let pong = try? await client.request(op: "ping", timeout: 5),
pong["version"]?.intValue == MacVMAgentWire.version
{
if live[name] != nil { live[name]?.agent = .available(client) }
return
}
await client.close()
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
throw MacVMError.agentUnavailable(
"the in-guest agent never answered a vsock ping for \"\(name)\" within the boot deadline")
#else
throw MacVMError.unavailable(Self.unsupportedReason ?? "requires Apple silicon")
#endif
}
/// Look up the IPv4 address currently leased to `mac` in `/var/db/dhcpd_leases` (written by
/// Apple's vmnet NAT DHCP server) — **display only** now (the NAT device exists purely for the
/// guest's own outbound internet; the control plane is vsock). Reads the file, then delegates to
/// the pure ``parseLeases``.
static func leaseIP(forMAC mac: String) -> String? {
guard let text = try? String(contentsOfFile: "/var/db/dhcpd_leases", encoding: .utf8)
else { return nil }
return parseLeases(text, forMAC: mac)
}
/// Pure parser for `dhcpd_leases` content: return the IPv4 leased to `mac`. MACs are compared
/// octet-by-octet as integers, since the lease file drops leading zeros (`2:34:…`) while
/// `VZMACAddress.string` keeps them (`02:34:…`). The last (newest) matching lease wins.
static func parseLeases(_ text: String, forMAC mac: String) -> String? {
let want = normalizeMAC(mac)
var currentIP: String?
var lastMatchIP: String?
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line == "{" { currentIP = nil }
else if line.hasPrefix("ip_address=") {
currentIP = String(line.dropFirst("ip_address=".count))
} else if line.hasPrefix("hw_address=") {
// Format: `hw_address=1,d6:a7:58:8e:78:d4` — strip the leading `<type>,`.
var hw = String(line.dropFirst("hw_address=".count))
if let comma = hw.firstIndex(of: ",") { hw = String(hw[hw.index(after: comma)...]) }
if normalizeMAC(hw) == want, let ip = currentIP { lastMatchIP = ip }
}
}
return lastMatchIP
}
/// Normalize a MAC to a colon-joined list of two-digit lowercase hex octets for comparison.
static func normalizeMAC(_ mac: String) -> String {
mac.split(separator: ":").compactMap { UInt8($0, radix: 16) }
.map { String(format: "%02x", $0) }.joined(separator: ":")
}
/// A fresh locally-administered MAC (the U/L bit set, multicast bit clear) as a colon string.
static func randomMAC() -> String {
#if arch(arm64)
return VZMACAddress.randomLocallyAdministered().string
#else
var octets = (0..<6).map { _ in UInt8.random(in: 0...255) }
octets[0] = (octets[0] & 0xFE) | 0x02
return octets.map { String(format: "%02x", $0) }.joined(separator: ":")
#endif
}
}
#if arch(arm64)
/// Owns one live `VZVirtualMachine` and the serial dispatch queue every VZ call must run on. VZ
/// objects are not `Sendable` and are only safe to touch on their own queue, so this box confines all
/// of that: it is `@unchecked Sendable`, never hands a VZ-typed value across its boundary, and bridges
/// the queue's completion-handler API to `async` via continuations. The engine actor holds these
/// boxes but performs every VM operation through this narrow, queue-correct surface.
final class MacVMInstance: NSObject, VZVirtualMachineDelegate, @unchecked Sendable {
let queue: DispatchQueue
/// The wrapped VM. Internal (not private) so the install extension in `MacVMEngine+Base` can drive
/// `VZMacOSInstaller` against it — but ONLY ever touched on `queue`.
let vm: VZVirtualMachine
/// Fired (once) when the guest powers itself off or stops with an error.
private var onStop: (@Sendable () -> Void)? = nil
init(configuration: VZVirtualMachineConfiguration, label: String, mainQueue: Bool = false) {
// Computer-use VMs run on the MAIN queue so a `VZVirtualMachineView` can bind to them (the
// view + its VM must share the main thread). Safe: `init` is called from the engine actor,
// which never runs on main, so `main.sync` below can't deadlock. Exec-only VMs keep a
// dedicated background queue. macOS caps concurrent macOS guests at 2, so main-queue VMs are
// not a scalability concern.
let q = mainQueue ? DispatchQueue.main : DispatchQueue(label: "xyz.blakeslee.nucleic.macvm.\(label)")
self.queue = q
self.vm = q.sync { VZVirtualMachine(configuration: configuration, queue: q) }
super.init()
q.sync { self.vm.delegate = self }
}
/// Start the guest; resumes once `VZVirtualMachine.start` reports success or throws its error.
func start() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
queue.async { [self] in
vm.start { result in
switch result {
case .success: cont.resume()
case .failure(let error): cont.resume(throwing: error)
}
}
}
}
}
/// Force-stop (power-off) the guest. Best-effort and idempotent — a clone is disposable, so this
/// doesn't bother with a graceful guest shutdown request.
func stop() async {
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
queue.async { [self] in
guard vm.canStop else { cont.resume(); return }
vm.stop { _ in cont.resume() }
}
}
}
/// Register a one-shot hook fired when the guest stops on its own (power-off / panic / error).
/// Assigned on `queue` so `onStop` stays confined to the VM queue — the same queue the delegate
/// callbacks (hence `fireStop`) run on, so there's no cross-thread access to the reference.
func setOnStop(_ hook: @escaping @Sendable () -> Void) {
queue.async { [self] in onStop = hook }
}
// VZVirtualMachineDelegate — the guest stopped on its own or errored. Fire the hook (once).
// Delivered by VZ on `queue`, so `onStop` is read/cleared here on its owning queue.
func guestDidStop(_ virtualMachine: VZVirtualMachine) { fireStop() }
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
fireStop()
}
private func fireStop() {
let hook = onStop
onStop = nil
hook?()
}
}
#endif