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

708 lines
40 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
/// Carries a value across an isolation boundary the compiler can't prove safe. Used to hand a
/// freshly-created, not-yet-shared non-`Sendable` VZ object (e.g. `VZMacOSRestoreImage`) out of a
/// framework completion handler through a `CheckedContinuation` — the object is created inside the
/// callback and touched nowhere else until it lands on the engine actor, so the transfer is a
/// disconnected hand-off despite the type lacking a `Sendable` conformance.
public struct UncheckedSendableBox<T>: @unchecked Sendable {
public let value: T
public init(value: T) { self.value = value }
}
/// On-disk layout of one macOS **VM bundle** — the golden base or a per-session clone. Mirrors the
/// bundle Apple's "Running macOS in a virtual machine on Apple silicon" sample uses: the platform
/// identity (`HardwareModel` + `MachineIdentifier`), the writable NVRAM (`AuxiliaryStorage`), and the
/// system disk (`Disk.img`). `MACAddress` (our addition) pins the clone's NAT MAC so its DHCP lease is
/// findable; `bundle.json` records how it was built.
public struct MacVMBundle: Sendable, Equatable {
public let root: URL
public init(root: URL) { self.root = root }
// macOS-guest artifacts (Mac platform identity + NVRAM).
public var hardwareModelURL: URL { root.appendingPathComponent("HardwareModel") }
public var machineIdentifierURL: URL { root.appendingPathComponent("MachineIdentifier") }
public var auxiliaryStorageURL: URL { root.appendingPathComponent("AuxiliaryStorage") }
/// The system disk — the macOS install, or (for a Linux guest) the ext4 root filesystem.
public var diskImageURL: URL { root.appendingPathComponent("Disk.img") }
public var macAddressURL: URL { root.appendingPathComponent("MACAddress") }
public var metadataURL: URL { root.appendingPathComponent("bundle.json") }
// Linux-guest artifacts (`VZLinuxBootLoader` boots an external kernel + initrd; there is no
// in-disk bootloader). The kernel is an *uncompressed* arm64 `Image`; the cmdline is persisted so
// a clone boots with the exact command line the base was provisioned under.
public var kernelURL: URL { root.appendingPathComponent("vmlinux") }
public var initrdURL: URL { root.appendingPathComponent("initrd.img") }
public var cmdlineURL: URL { root.appendingPathComponent("cmdline") }
/// True when every artifact needed to boot a **macOS** guest is present.
public var isComplete: Bool { isComplete(for: .macOS) }
/// True when every artifact needed to boot `os` is present. A macOS guest needs the Mac platform
/// identity + NVRAM + disk; a Linux guest needs the kernel + initrd + rootfs disk.
public func isComplete(for os: GuestOS) -> Bool {
let fm = FileManager.default
let required: [URL]
switch os {
case .macOS:
required = [hardwareModelURL, machineIdentifierURL, auxiliaryStorageURL, diskImageURL]
case .linux:
required = [kernelURL, initrdURL, diskImageURL]
}
return required.allSatisfy { fm.fileExists(atPath: $0.path) }
}
}
extension MacVMEngine {
// MARK: - Base bundle resolution
/// Resolve the golden base bundle to clone from, without ever kicking off the (heavy, interactive)
/// build implicitly. Order: a user-supplied **prebuilt** bundle if configured and complete, else
/// the engine's own built base if present. When neither exists, throw `.baseImageMissing` with a
/// pointer to the explicit build path — the ~14 GB install + toolchain provisioning is a deliberate
/// one-time action (Settings ▸ Virtual Machines ▸ Build base image, `macvm-spike`, or
/// `scripts/build-macos-base.sh`), never a surprise on an agent's first turn.
func ensureBaseBundle(for os: GuestOS = .macOS) async throws -> MacVMBundle {
if os == .linux { return try ensureLinuxBaseBundle() }
if let prebuilt = MacVMSettings.basePrebuiltPath {
let bundle = MacVMBundle(root: URL(fileURLWithPath: prebuilt))
if bundle.isComplete { return bundle }
throw MacVMError.baseImageMissing(
"the configured prebuilt base at \(prebuilt) is missing its VM artifacts")
}
let built = MacVMBundle(root: builtBaseDir)
if built.isComplete { return built }
throw MacVMError.baseImageMissing(
"no base macOS image has been built yet. Build it once via Settings ▸ Virtual Machines ▸ "
+ "Build base image, or `scripts/build-macos-base.sh`.")
}
/// The installed guest macOS version of the current base bundle (read from `bundle.json`), for the
/// settings panel. `nil` when no base exists, or when a prebuilt bundle was produced out-of-band
/// without version metadata.
public func baseImageOSVersion() -> String? { currentBaseStatus().osVersion }
/// The current base bundle's provisioning status (installed / exec-ready / computer-use-ready),
/// for the settings panel. Prefers a configured prebuilt bundle, else the engine-built base;
/// all-false when neither is complete.
public func currentBaseStatus() -> MacVMBaseStatus {
let bundle = MacVMSettings.basePrebuiltPath.map { MacVMBundle(root: URL(fileURLWithPath: $0)) }
?? MacVMBundle(root: builtBaseDir)
guard bundle.isComplete else { return MacVMBaseStatus() }
var status = readBaseStatus(bundle)
status.installed = true // a complete bundle is installed even if it predates the metadata
return status
}
/// Filesystem path of the current (complete) base bundle — the prebuilt one if configured, else
/// the engine-built base — or `nil` when none exists. The Recovery helper builds a VZ config from
/// it (via ``makeConfiguration``) to boot the base into recoveryOS for the one-time `csrutil disable`.
public func baseBundleRootPath() -> String? {
let bundle = MacVMSettings.basePrebuiltPath.map { MacVMBundle(root: URL(fileURLWithPath: $0)) }
?? MacVMBundle(root: builtBaseDir)
return bundle.isComplete ? bundle.root.path : nil
}
/// Claim the base for an exclusive **Recovery** boot (the app-layer window boots it writable, so
/// the engine must know to refuse builds + fresh clones meanwhile). Returns the base path on
/// success; throws ``MacVMError/baseBusy(_:)`` if the base is already building or in Recovery, or
/// ``MacVMError/baseImageMissing(_:)`` when there's no base to boot. Pair with ``endBaseRecovery()``.
public func beginBaseRecovery() throws -> String {
guard !baseIsBusy else {
throw MacVMError.baseBusy(
baseBuilding ? "a base image build is in progress" : "a Recovery session is already open")
}
guard let path = baseBundleRootPath() else {
throw MacVMError.baseImageMissing("no base image has been built yet")
}
baseRecoveryActive = true
syncBackgroundActivity() // the Recovery VM never enters `live` — keep the app off App Nap
return path
}
/// Release the base after a Recovery window closes (its VM powered off), re-allowing builds/clones.
public func endBaseRecovery() {
baseRecoveryActive = false
syncBackgroundActivity()
}
/// Delete the engine-built golden base so the next ``buildBaseImage`` reinstalls macOS **from
/// scratch** (the full install path, not the reentrant re-provision). Removes the base bundle and,
/// when `includingRestoreImages` is set, the cached `.ipsw` too — so the restore image is also
/// re-downloaded. Refuses while the base is busy (a build in flight or a Recovery window open), so
/// it can't yank the disk out from under a running install. A configured *prebuilt* base is left
/// untouched (Nucleic didn't create it); this only clears `builtBaseDir`.
public func deleteBaseImage(includingRestoreImages: Bool) throws {
guard !baseIsBusy else {
throw MacVMError.baseBusy(
baseBuilding
? "a base image build is in progress — wait for it to finish, then delete."
: "the base is open in a Recovery window — close it first.")
}
// Before wiping the bundle (and its `bundle.json`), stash the apps/packages the user had
// installed into it so the next build restores them into the replacement base — an OS-version
// update is a delete-then-reinstall, and the on-disk record is gone by the time that build runs
// (docs/MACOS_VM.md §9.1). The current "Included apps" / "Common packages" settings are re-staged
// regardless; this preserves the extras that had dropped off those lists.
let outgoing = readBaseStatus(MacVMBundle(root: builtBaseDir))
MacVMSettings.setPendingBaseCarry(
apps: outgoing.installedAppPaths, packages: outgoing.installedPackageIDs)
let fm = FileManager.default
try? fm.removeItem(at: builtBaseDir)
if includingRestoreImages { try? fm.removeItem(at: restoreDir) }
baseProgress = nil
}
/// Read the base's provisioning status from `bundle.json` (all-false when absent/unreadable).
func readBaseStatus(_ bundle: MacVMBundle) -> MacVMBaseStatus {
guard let data = try? Data(contentsOf: bundle.metadataURL),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return MacVMBaseStatus() }
return MacVMBaseStatus(json: json)
}
/// Persist the base's provisioning status to `bundle.json`.
func writeBaseStatus(_ bundle: MacVMBundle, _ status: MacVMBaseStatus) {
if let data = try? JSONSerialization.data(withJSONObject: status.dictionary) {
try? data.write(to: bundle.metadataURL)
}
}
// MARK: - Cloning
/// Produce a per-session clone of `base`: copy the small identity + NVRAM files, copy-on-write the
/// large system disk (instant + space-efficient on APFS), and assign a fresh NAT MAC. Returns the
/// assigned MAC. The clone is what the session actually boots — the base stays pristine.
func cloneBase(_ base: MacVMBundle, to dest: MacVMBundle, os: GuestOS = .macOS) throws -> String {
let fm = FileManager.default
do {
try? fm.removeItem(at: dest.root)
try fm.createDirectory(at: dest.root, withIntermediateDirectories: true)
switch os {
case .macOS:
try fm.copyItem(at: base.hardwareModelURL, to: dest.hardwareModelURL)
try fm.copyItem(at: base.machineIdentifierURL, to: dest.machineIdentifierURL)
// Aux storage (NVRAM) must be per-VM writable, so each clone gets its own copy.
try fm.copyItem(at: base.auxiliaryStorageURL, to: dest.auxiliaryStorageURL)
case .linux:
// No Mac platform identity/NVRAM. The kernel + initrd + cmdline are read-only and
// small, so copy them straight through (the clone boots the same kernel as the base).
try fm.copyItem(at: base.kernelURL, to: dest.kernelURL)
try fm.copyItem(at: base.initrdURL, to: dest.initrdURL)
if fm.fileExists(atPath: base.cmdlineURL.path) {
try fm.copyItem(at: base.cmdlineURL, to: dest.cmdlineURL)
}
}
// The multi-GB system disk (macOS install / Linux rootfs): clone copy-on-write via
// clonefile(2). Falls back to a full copy on a non-APFS volume (clonefile returns non-zero).
if clonefile(base.diskImageURL.path, dest.diskImageURL.path, 0) != 0 {
try fm.copyItem(at: base.diskImageURL, to: dest.diskImageURL)
}
} catch {
throw MacVMError.cloneFailed(String(describing: error))
}
let mac = Self.randomMAC()
try? mac.write(to: dest.macAddressURL, atomically: true, encoding: .utf8)
return mac
}
}
#if arch(arm64)
extension MacVMEngine {
// MARK: - VM configuration builder
/// Assemble the `VZVirtualMachineConfiguration` for a bundle: reconstruct the persisted Mac
/// platform (hardware model + machine identifier + this bundle's aux storage), clamp CPU/memory to
/// the framework's allowed range, and attach the standard device set. A display, keyboard, and
/// trackpad are included so the *same* config can be shown in a window while provisioning the base
/// (Setup Assistant needs a display); clones run headless and simply never present the view.
///
/// `nonisolated static` on purpose: it reads only the (Sendable) bundle files and constructs VZ
/// objects — no engine-actor state — so both the actor and the main-actor GUI window (the Recovery
/// helper) can build a config from the same code without crossing isolation.
public nonisolated static func makeConfiguration(
bundle: MacVMBundle, os: GuestOS = .macOS, cpus: Int, memoryGiB: Int, mac: String,
mounts: [MacVMSpec.Mount]
) throws -> VZVirtualMachineConfiguration {
switch os {
case .macOS:
return try makeMacConfiguration(
bundle: bundle, cpus: cpus, memoryGiB: memoryGiB, mac: mac, mounts: mounts)
case .linux:
return try makeLinuxConfiguration(
bundle: bundle, cpus: cpus, memoryGiB: memoryGiB, mac: mac, mounts: mounts)
}
}
/// Assemble the config for a **macOS** guest (the original ``makeConfiguration`` body): the Mac
/// platform + `VZMacOSBootLoader` + `VZMac*` graphics/keyboard/trackpad.
public nonisolated static func makeMacConfiguration(
bundle: MacVMBundle, cpus: Int, memoryGiB: Int, mac: String, mounts: [MacVMSpec.Mount]
) throws -> VZVirtualMachineConfiguration {
let config = VZVirtualMachineConfiguration()
config.platform = try loadMacPlatform(bundle: bundle)
config.bootLoader = VZMacOSBootLoader()
config.cpuCount = Self.clampCPU(cpus)
config.memorySize = Self.clampMemory(UInt64(memoryGiB) * 1024 * 1024 * 1024)
// System disk (this bundle's writable Disk.img).
let attachment = try VZDiskImageStorageDeviceAttachment(url: bundle.diskImageURL, readOnly: false)
config.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
// NAT networking — kept **only for the guest's own outbound internet** (brew / npm / xcodebuild
// dependencies). It is NOT part of the host↔guest control plane anymore: that is 100% vsock (no
// SSH, no DHCP-lease discovery, no incoming-connection/local-network permission surface). NAT
// needs no restricted entitlement — deliberately NOT bridged (`com.apple.vm.networking` would
// get an ad-hoc-signed binary killed at launch; see signing/spike.entitlements). The pinned MAC
// is still used for the best-effort IP shown in the settings panel.
let net = VZVirtioNetworkDeviceConfiguration()
net.macAddress = VZMACAddress(string: mac) ?? VZMACAddress.randomLocallyAdministered()
net.attachment = VZNATNetworkDeviceAttachment()
config.networkDevices = [net]
// vsock channel for the native in-guest agent (docs/MACOS_VM_NATIVE_AGENT.md §3) — the **sole**
// host↔guest channel: the host reaches the agent via VZVirtioSocketDevice.connect(toPort:) for
// exec, computer-use, and readiness. Exactly one socket device per VM (the framework's limit);
// harmless on the clean install during a base build, before the agent is installed.
config.socketDevices = [VZVirtioSocketDeviceConfiguration()]
config.graphicsDevices = [Self.makeGraphics()]
config.keyboards = [VZMacKeyboardConfiguration()]
config.pointingDevices = [VZMacTrackpadConfiguration()]
config.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
config.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
// Shared host directories → virtiofs. The macOS guest auto-mounts the automount-tagged device
// under `/Volumes/My Shared Files/<name>/`, no in-guest action required.
if !mounts.isEmpty {
var shares: [String: VZSharedDirectory] = [:]
for m in mounts {
shares[m.name] = VZSharedDirectory(
url: URL(fileURLWithPath: m.host), readOnly: m.readOnly)
}
let fs = VZVirtioFileSystemDeviceConfiguration(
tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag)
fs.share = VZMultipleDirectoryShare(directories: shares)
config.directorySharingDevices = [fs]
}
try config.validate()
return config
}
/// Reconstruct the Mac platform from a bundle's persisted identity + this bundle's aux storage.
nonisolated private static func loadMacPlatform(bundle: MacVMBundle) throws -> VZMacPlatformConfiguration {
let platform = VZMacPlatformConfiguration()
platform.auxiliaryStorage = VZMacAuxiliaryStorage(url: bundle.auxiliaryStorageURL)
guard let hwData = try? Data(contentsOf: bundle.hardwareModelURL),
let hardwareModel = VZMacHardwareModel(dataRepresentation: hwData)
else { throw MacVMError.startFailed("unreadable hardware model in bundle") }
guard hardwareModel.isSupported else {
throw MacVMError.startFailed("this Mac can't run the base image's hardware model")
}
platform.hardwareModel = hardwareModel
guard let idData = try? Data(contentsOf: bundle.machineIdentifierURL),
let machineIdentifier = VZMacMachineIdentifier(dataRepresentation: idData)
else { throw MacVMError.startFailed("unreadable machine identifier in bundle") }
platform.machineIdentifier = machineIdentifier
return platform
}
static func makeGraphics() -> VZMacGraphicsDeviceConfiguration {
let g = VZMacGraphicsDeviceConfiguration()
// A 3840×2400 panel at ~220 PPI (real-Retina density) so the guest renders "looks like
// 1920×1200" @ 2×, matching a live Retina Mac rather than the coarse 1× a low-PPI panel
// forces. The *logical* desktop stays 1920×1200 pts — the same workspace the 1×/80-PPI
// panel gave — so window layouts are unaffected. Deliberately not a smaller panel at high
// PPI: that would keep 2× but halve the logical desktop to 960×600, too small for the main
// window's minimum, reintroducing the squeezed/overlapping layout. The host-side
// computer-use surface is decoupled from this (its VZVirtualMachineView is a fixed
// 1920×1200-pt view whose captures normalize to `MacVMComputerSurface.fbWidth/Height`), so
// agent screenshots and click mapping stay 1:1 at 1920×1200 regardless of this density.
g.displays = [
VZMacGraphicsDisplayConfiguration(widthInPixels: 3840, heightInPixels: 2400, pixelsPerInch: 220)
]
return g
}
static func clampCPU(_ requested: Int) -> Int {
var n = requested
n = max(n, VZVirtualMachineConfiguration.minimumAllowedCPUCount)
n = min(n, VZVirtualMachineConfiguration.maximumAllowedCPUCount)
// Never claim more than the host has, less one core for the host itself.
n = min(n, max(1, ProcessInfo.processInfo.processorCount - 1))
return max(1, n)
}
static func clampMemory(_ requested: UInt64) -> UInt64 {
var m = requested
m = max(m, VZVirtualMachineConfiguration.minimumAllowedMemorySize)
m = min(m, VZVirtualMachineConfiguration.maximumAllowedMemorySize)
return m
}
// MARK: - Base install (one-time)
/// Build the golden base bundle: resolve a macOS restore image (a configured local `.ipsw`, or the
/// latest supported fetched over the network), install macOS into a fresh bundle, and record
/// metadata. This installs a *clean* macOS — the guest then sits at Setup Assistant. Turning it into
/// a usable base (an `agent` account, Remote Login on, the dev toolchain, Nucleic's SSH key) is the
/// **provisioning** step, driven from a display session + `scripts/provision-macos-guest.sh`
/// (see docs/MACOS_VM.md); Apple provides no unattended-install path for macOS guests outside MDM.
///
/// Long-running (a ~14 GB download on first use, then a multi-minute install); `progress` is
/// reported through ``currentBaseProgress()``. Intended to be invoked explicitly (settings action /
/// spike / script), never lazily on an agent turn.
public func buildBaseImage(localRestoreImagePath: String? = nil) async throws {
guard Self.isSupported else {
throw MacVMError.unavailable(Self.unsupportedReason ?? "requires Apple silicon")
}
// Serialize base builds: two concurrent ones share the `base.building` temp path and would
// delete each other's work-in-progress; a Recovery session has the base booted writable. The
// check+set is atomic on the actor (no await between).
guard !baseIsBusy else {
throw MacVMError.installFailed(
baseRecoveryActive
? "the base image is open in a Recovery window — close it first"
: "a base image build is already in progress")
}
baseBuilding = true
// Hold the anti-nap assertion for the whole build/provision pass: the base VM booted below never
// enters `live`, so without this an auto-rebuild that fires at launch (e.g. after an update,
// while the app is still backgrounded) would be App-Napped, freezing the main-queue provisioning
// VM + its HID and wedging the build at "Waiting for the guest desktop". Released in the defer.
syncBackgroundActivity()
defer {
baseBuilding = false
syncBackgroundActivity()
}
let fm = FileManager.default
// Reentrancy: if the base is already installed + account-provisioned, skip the multi-GB
// install and (re)run only the toolchain/agent provisioning pass. This is the "disable SIP,
// click Build again" path, and lets a user re-provision after enabling computer use.
let existingBase = MacVMBundle(root: builtBaseDir)
if existingBase.isComplete, MacVMSettings.basePrebuiltPath == nil {
let status = readBaseStatus(existingBase)
guard status.accountProvisioned else {
throw MacVMError.provisionFailed(
"the installed base has no key-authorized `agent` account yet — finish account "
+ "setup first (docs/MACOS_VM.md §4).")
}
// Reentrant re-provision: the account already exists, so this is a normal boot (no
// declarative first-boot options). Carry this base's own recorded apps/packages forward so
// re-provisioning re-stages anything the user installed here that's since been dropped from
// the live Settings list. (A carry stash from an aborted delete is folded in too, then
// cleared once the pass succeeds.)
try await provisionAndFinalize(
bundle: existingBase, base: status, declarativeFirstBoot: false,
carryAppPaths: Self.orderedUnion(
status.installedAppPaths, MacVMSettings.pendingBaseCarryApps),
carryPackageIDs: Self.orderedUnion(
status.installedPackageIDs, MacVMSettings.pendingBaseCarryPackages))
MacVMSettings.clearPendingBaseCarry()
return
}
try fm.createDirectory(at: restoreDir, withIntermediateDirectories: true)
let restoreURL = try await resolveRestoreImage(localPath: localRestoreImagePath)
let restoreImage = try await Self.loadRestoreImage(at: restoreURL)
let osv = restoreImage.operatingSystemVersion
let guestVersion = "\(osv.majorVersion).\(osv.minorVersion).\(osv.patchVersion)"
guard let requirements = restoreImage.mostFeaturefulSupportedConfiguration,
requirements.hardwareModel.isSupported
else {
// The host's Virtualization framework can't run this image's hardware model — almost always
// because the guest macOS is newer than the host (a macOS N guest needs a macOS ≥N host).
let h = ProcessInfo.processInfo.operatingSystemVersion
throw MacVMError.installFailed(
"this restore image (macOS \(guestVersion)) can't run on this host "
+ "(macOS \(h.majorVersion).\(h.minorVersion).\(h.patchVersion)). A macOS "
+ "\(osv.majorVersion) guest requires a macOS \(osv.majorVersion)+ host — update the "
+ "host, or pin an older restore image (Settings ▸ Virtual Machines).")
}
baseProgress = MacVMBaseProgress(phase: .installing, fraction: 0)
// Build into a temp bundle, then publish atomically so an interrupted install never leaves a
// half-built base that `ensureBaseBundle` would treat as usable.
let tmpBundle = MacVMBundle(
root: storageRoot.appendingPathComponent("base.building", isDirectory: true))
try? fm.removeItem(at: tmpBundle.root)
try fm.createDirectory(at: tmpBundle.root, withIntermediateDirectories: true)
// Set inside the install do-block once the base is published; the provisioning pass then runs
// AFTER the catch, so its (non-install) errors aren't wrapped with the install-failure hint.
// `declarativeFirstBoot` is true for a macOS-27 guest: the provisioning boot IS the first boot
// after restore, so it carries `VZMacGuestProvisioningOptions` to create the `agent` account.
var provisionTarget:
(bundle: MacVMBundle, status: MacVMBaseStatus, declarativeFirstBoot: Bool,
carryApps: [String], carryPackages: [String])?
do {
try createInstallPlatform(into: tmpBundle, requirements: requirements)
try Self.createDiskImage(
at: tmpBundle.diskImageURL,
sizeBytes: UInt64(MacVMSettings.baseDiskGiB) * 1024 * 1024 * 1024)
// The install MAC is reused for the (macOS 27+) first-boot provisioning pass below —
// the DHCP lease keyed to it is how that pass finds the guest's IP.
let installMAC = Self.randomMAC()
let config = try Self.makeConfiguration(
bundle: tmpBundle,
cpus: max(requirements.minimumSupportedCPUCount, MacVMSettings.defaultVMCPUs),
memoryGiB: MacVMSettings.defaultVMMemoryGiB,
mac: installMAC, mounts: [])
let instance = MacVMInstance(configuration: config, label: "base-install")
try await instance.installMacOS(restoreImageURL: restoreURL) { [weak self] fraction in
Task { await self?.setBaseProgress(.init(phase: .installing, fraction: fraction)) }
}
await instance.stop()
// This build REPLACES whatever base was at `builtBaseDir`. Recover the apps/packages the
// user had installed into the old base so the provisioning pass below re-installs them into
// the new base automatically — even ones since dropped from the live Settings list
// (docs/MACOS_VM.md §9.1). Source: the old bundle's `bundle.json` if it's still on disk
// (an in-place reinstall), unioned with the carry stash written by `deleteBaseImage` (the
// OS-version-update path, where the bundle was already removed). Empty on a first-ever build.
let priorBase = readBaseStatus(MacVMBundle(root: builtBaseDir))
let carryApps = Self.orderedUnion(
priorBase.installedAppPaths, MacVMSettings.pendingBaseCarryApps)
let carryPackages = Self.orderedUnion(
priorBase.installedPackageIDs, MacVMSettings.pendingBaseCarryPackages)
// Publish the clean install FIRST (so a later provisioning failure can't discard the
// multi-GB install), then provision it in place. `accountProvisioned` starts false and is
// set true by the provisioning pass once the declarative first boot has created the account.
let installedStatus = MacVMBaseStatus(
installed: true, accountProvisioned: false,
osVersion: guestVersion, buildVersion: restoreImage.buildVersion)
writeBaseStatus(tmpBundle, installedStatus)
try? fm.removeItem(at: builtBaseDir)
try fm.moveItem(at: tmpBundle.root, to: builtBaseDir)
if #available(macOS 27.0, *), osv.majorVersion >= 27 {
// macOS 27 guest: the provisioning boot is the first boot after restore and carries
// `VZMacGuestProvisioningOptions` to create the `agent` account + auto-login (no Remote
// Login — the control plane is vsock). See `provisionBase` (docs/MACOS_VM.md §4.4).
provisionTarget = (
MacVMBundle(root: builtBaseDir), installedStatus, true, carryApps, carryPackages)
} else {
// Unsupported (pre-27) guest: no unattended account-creation path — declarative
// provisioning is macOS 27+, and we don't drive Setup Assistant. Leave the installed
// base for the manual path (§4.2).
baseProgress = MacVMBaseProgress(phase: .ready, fraction: 1)
}
} catch {
try? fm.removeItem(at: tmpBundle.root)
baseProgress = nil
if let e = error as? MacVMError { throw e }
// A newer-than-host guest can pass the hardware-model check yet still fail to install:
// `VZMacOSInstaller` can't install a guest newer than the host. Add an actionable hint.
let hostMajor = ProcessInfo.processInfo.operatingSystemVersion.majorVersion
var hint = ""
if osv.majorVersion > hostMajor {
hint = " — note: installing a macOS \(osv.majorVersion) guest from a macOS \(hostMajor) "
+ "host is not supported: the guest can't be newer than the host, and macOS 27 is "
+ "required. Build the base on a host running at least macOS \(osv.majorVersion) "
+ "(Settings ▸ Virtual Machines)."
}
throw MacVMError.installFailed(String(describing: error) + hint)
}
// The clean install is published; now create the account (declarative first boot) and provision
// the toolchain + native agent — all host-side (HID bootstrap) + VirtioFS, no network.
if let target = provisionTarget {
try await provisionAndFinalize(
bundle: target.bundle, base: target.status,
declarativeFirstBoot: target.declarativeFirstBoot,
carryAppPaths: target.carryApps, carryPackageIDs: target.carryPackages)
// The replacement base's `bundle.json` now records the carried set, so the delete-time
// stash has done its job — clear it (a provisioning failure above leaves it for a retry).
MacVMSettings.clearPendingBaseCarry()
}
}
func setBaseProgress(_ p: MacVMBaseProgress) { baseProgress = p }
/// Create a fresh install-time platform: a new aux storage bound to the restore image's hardware
/// model, a new machine identifier, and both persisted into the bundle for later reload.
private func createInstallPlatform(
into bundle: MacVMBundle, requirements: VZMacOSConfigurationRequirements
) throws {
let platform = VZMacPlatformConfiguration()
let aux = try VZMacAuxiliaryStorage(
creatingStorageAt: bundle.auxiliaryStorageURL,
hardwareModel: requirements.hardwareModel, options: [])
platform.auxiliaryStorage = aux
platform.hardwareModel = requirements.hardwareModel
platform.machineIdentifier = VZMacMachineIdentifier()
try platform.hardwareModel.dataRepresentation.write(to: bundle.hardwareModelURL)
try platform.machineIdentifier.dataRepresentation.write(to: bundle.machineIdentifierURL)
}
/// Resolve the restore image to a local `.ipsw` path, choosing the guest macOS version (up to what
/// the host supports — a macOS N guest needs a macOS ≥N host). Precedence:
/// 1. an explicit local `.ipsw` (`localPath` arg or `restoreImagePath` setting) — pins a version;
/// 2. a configured remote `.ipsw` URL (`restoreImageURL` setting) — pins a version, downloaded;
/// 3. `VZMacOSRestoreImage.latestSupported` — the newest the host can run (macOS 27 on a 27 host).
///
/// Downloads are cached **keyed by the IPSW's filename** (which encodes the version/build), NOT a
/// fixed `latest.ipsw`. That's the fix that lets the fetch path move up to macOS 27: when Apple
/// publishes a newer version, `latestSupported.url`'s filename changes, so a fresh image is fetched
/// instead of a stale one being reused forever.
private func resolveRestoreImage(localPath: String?) async throws -> URL {
if let localPath = localPath ?? MacVMSettings.restoreImagePath {
let url = URL(fileURLWithPath: localPath)
guard FileManager.default.fileExists(atPath: url.path) else {
throw MacVMError.installFailed("restore image not found at \(localPath)")
}
return url
}
if let urlString = MacVMSettings.restoreImageURL, let remote = URL(string: urlString) {
return try await downloadRestoreImageIfNeeded(from: remote)
}
// Resolve the latest image's metadata first (a cheap network call) so the cache key tracks the
// actual image identity.
baseProgress = MacVMBaseProgress(phase: .downloadingRestoreImage, fraction: 0)
let latest = try await Self.fetchLatestRestoreImage()
return try await downloadRestoreImageIfNeeded(from: latest.url)
}
/// Download `remote` into the version-keyed cache (`restore/<ipsw-filename>`) unless already present.
private func downloadRestoreImageIfNeeded(from remote: URL) async throws -> URL {
let fm = FileManager.default
try fm.createDirectory(at: restoreDir, withIntermediateDirectories: true)
let cached = restoreDir.appendingPathComponent(Self.restoreCacheName(for: remote))
if fm.fileExists(atPath: cached.path) { return cached }
baseProgress = MacVMBaseProgress(phase: .downloadingRestoreImage, fraction: 0)
try await downloadFile(from: remote, to: cached) { [weak self] fraction in
Task { await self?.setBaseProgress(.init(phase: .downloadingRestoreImage, fraction: fraction)) }
}
return cached
}
/// A stable, filesystem-safe cache filename for a restore-image URL: the IPSW's own filename (which
/// encodes the macOS version/build, e.g. `UniversalMac_26.0_25A...ipsw`), so different versions land
/// in different cache files and re-selecting the same version reuses the download.
static func restoreCacheName(for url: URL) -> String {
var leaf = url.lastPathComponent
if leaf.isEmpty || leaf == "/" { leaf = "restore" }
// Sanitize any stray path separators / query artifacts.
leaf = leaf.map { ($0 == "/" || $0 == ":" || $0 == "?") ? "_" : $0 }.reduce(into: "") { $0.append($1) }
return leaf.hasSuffix(".ipsw") ? leaf : leaf + ".ipsw"
}
/// Retains a KVO observation across an async operation. `NSKeyValueObservation` stops firing the
/// moment it deinits, so a progress observation must be held until the download completes — a bare
/// `withExtendedLifetime` wouldn't span the async gap.
private final class ProgressRetainer: @unchecked Sendable {
var observation: NSKeyValueObservation?
}
/// Download a URL to `dest`, reporting `\.fractionCompleted` as it streams (the restore image is
/// ~14 GB, so a determinate bar matters). Internal (not private) so the Linux base build reuses it
/// for the kernel / initrd / rootfs downloads.
func downloadFile(
from url: URL, to dest: URL, onFraction: @escaping @Sendable (Double) -> Void
) async throws {
let retainer = ProgressRetainer()
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
let task = URLSession.shared.downloadTask(with: url) { tmp, _, error in
retainer.observation?.invalidate() // keeps `retainer` (hence the observation) alive to here
if let error { cont.resume(throwing: error); return }
guard let tmp else {
cont.resume(throwing: MacVMError.installFailed("download produced no file"))
return
}
do {
try? FileManager.default.removeItem(at: dest)
try FileManager.default.moveItem(at: tmp, to: dest)
cont.resume()
} catch { cont.resume(throwing: error) }
}
retainer.observation = task.progress.observe(\.fractionCompleted, options: [.new]) { p, _ in
onFraction(p.fractionCompleted)
}
task.resume()
}
}
// MARK: - Restore-image API bridges (completion-handler → async)
static func fetchLatestRestoreImage() async throws -> VZMacOSRestoreImage {
// `VZMacOSRestoreImage` isn't Sendable; box it to cross the completion-handler → continuation
// boundary (it's freshly minted here and untouched until it reaches the actor — a safe hand-off).
let box: UncheckedSendableBox<VZMacOSRestoreImage> = try await withCheckedThrowingContinuation {
cont in
VZMacOSRestoreImage.fetchLatestSupported { result in
switch result {
case .success(let image): cont.resume(returning: UncheckedSendableBox(value: image))
case .failure(let error): cont.resume(throwing: error)
}
}
}
return box.value
}
static func loadRestoreImage(at url: URL) async throws -> VZMacOSRestoreImage {
let box: UncheckedSendableBox<VZMacOSRestoreImage> = try await withCheckedThrowingContinuation {
cont in
VZMacOSRestoreImage.load(from: url) { result in
switch result {
case .success(let image): cont.resume(returning: UncheckedSendableBox(value: image))
case .failure(let error): cont.resume(throwing: error)
}
}
}
return box.value
}
/// Create a sparse disk image file of `sizeBytes` (grows on demand on APFS).
static func createDiskImage(at url: URL, sizeBytes: UInt64) throws {
let fm = FileManager.default
if fm.fileExists(atPath: url.path) { return }
guard fm.createFile(atPath: url.path, contents: nil) else {
throw MacVMError.installFailed("could not create disk image at \(url.path)")
}
let fh = try FileHandle(forWritingTo: url)
defer { try? fh.close() }
try fh.truncate(atOffset: sizeBytes)
}
}
extension MacVMInstance {
/// Install macOS into this instance's VM from a restore `.ipsw`, reporting install progress. Runs
/// the `VZMacOSInstaller` on the VM queue (as required) and resumes when it finishes or errors.
func installMacOS(
restoreImageURL: URL, progress: @escaping @Sendable (Double) -> Void
) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
queue.async { [self] in
let installer = VZMacOSInstaller(
virtualMachine: vm, restoringFromImageAt: restoreImageURL)
let observation = installer.progress.observe(\.fractionCompleted, options: [.new]) { p, _ in
progress(p.fractionCompleted)
}
installer.install { result in
observation.invalidate()
switch result {
case .success: cont.resume()
case .failure(let error): cont.resume(throwing: error)
}
}
}
}
}
}
#endif