834 lines
44 KiB
Swift
834 lines
44 KiB
Swift
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
|