708 lines
40 KiB
Swift
708 lines
40 KiB
Swift
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
|