Files
UTM/Configuration/UTMAppleConfiguration.swift
osy f4a3c5ac7c snapshots: manage Apple VM snapshots
Apple virtualization reported snapshots as unsupported. It has no notion of
them, but the pieces are there: the disks can be layered and the saved
state and firmware storage are ordinary files.

Add a backend that freezes a layer per drive and keeps copies of the saved
state, the EFI variables and the macOS auxiliary storage beside them. The
layers under a disk that is attached cannot be changed, so a running VM may
only delete; everything else waits until it stops.

A layer sits beside the image it belongs to and is named after it, and the saved
state and firmware of a snapshot are copies named the same way. A bundle only
keeps the files its configuration names, so saving the configuration would take
all of them with it. Work out what the snapshots still need instead — the layer
being written to, the layers of the snapshots in the bundle, and whatever lies
underneath those — and keep exactly that, so anything left over is still cleaned
up. When that cannot be worked out, because the manifest or a layer cannot be
read, everything named after the file is kept instead.

Assisted-by: Claude:claude-fable-5-1
Assisted-by: Claude:claude-opus-5
2026-09-22 10:57:30 -05:00

393 lines
15 KiB
Swift

//
// Copyright © 2022 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Virtualization
@available(iOS, unavailable, message: "Apple Virtualization not available on iOS")
final class UTMAppleConfiguration: UTMConfiguration {
/// Basic information and icon
@Published var _information: UTMConfigurationInfo = .init()
@Published private var _system: UTMAppleConfigurationSystem = .init()
@Published private var _virtualization: UTMAppleConfigurationVirtualization = .init()
@Published private var _sharedDirectories: [UTMAppleConfigurationSharedDirectory] = []
@Published private var _displays: [UTMAppleConfigurationDisplay] = []
@Published private var _drives: [UTMAppleConfigurationDrive] = []
@Published private var _networks: [UTMAppleConfigurationNetwork] = [.init()]
@Published private var _serials: [UTMAppleConfigurationSerial] = []
var backend: UTMBackend {
.apple
}
enum CodingKeys: String, CodingKey {
case information = "Information"
case system = "System"
case virtualization = "Virtualization"
case sharedDirectories = "SharedDirectory" // legacy
case displays = "Display"
case drives = "Drive"
case networks = "Network"
case serials = "Serial"
case backend = "Backend"
case configurationVersion = "ConfigurationVersion"
}
init() {
}
required init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
let backend = try values.decodeIfPresent(UTMBackend.self, forKey: .backend) ?? .unknown
guard backend == .apple else {
throw UTMConfigurationError.invalidBackend
}
let version = try values.decodeIfPresent(Int.self, forKey: .configurationVersion) ?? 0
guard version >= Self.oldestVersion else {
throw UTMConfigurationError.versionTooLow
}
guard version <= Self.currentVersion else {
throw UTMConfigurationError.versionTooHigh
}
_information = try values.decode(UTMConfigurationInfo.self, forKey: .information)
_system = try values.decode(UTMAppleConfigurationSystem.self, forKey: .system)
_virtualization = try values.decode(UTMAppleConfigurationVirtualization.self, forKey: .virtualization)
_sharedDirectories = try values.decodeIfPresent([UTMAppleConfigurationSharedDirectory].self, forKey: .sharedDirectories) ?? []
_displays = try values.decode([UTMAppleConfigurationDisplay].self, forKey: .displays)
_drives = try values.decode([UTMAppleConfigurationDrive].self, forKey: .drives)
_networks = try values.decode([UTMAppleConfigurationNetwork].self, forKey: .networks)
_serials = try values.decode([UTMAppleConfigurationSerial].self, forKey: .serials)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(_information, forKey: .information)
try container.encode(_system, forKey: .system)
try container.encode(_virtualization, forKey: .virtualization)
try container.encode(_displays, forKey: .displays)
try container.encode(_drives, forKey: .drives)
try container.encode(_networks, forKey: .networks)
try container.encode(_serials, forKey: .serials)
try container.encode(UTMBackend.apple, forKey: .backend)
try container.encode(Self.currentVersion, forKey: .configurationVersion)
}
}
enum UTMAppleConfigurationError: Error {
case notAppleConfiguration
case platformUnsupported
case kernelNotSpecified
case hardwareModelInvalid
case rosettaNotSupported
case featureNotSupported
case secureBootEnrollmentFailed
case efiVariablesInaccessible
}
extension UTMAppleConfigurationError: LocalizedError {
var errorDescription: String? {
switch self {
case .notAppleConfiguration:
return NSLocalizedString("This is not a valid Apple Virtualization configuration.", comment: "UTMAppleConfiguration")
case .platformUnsupported:
return NSLocalizedString("This virtual machine cannot run on the current host machine.", comment: "UTMAppleConfiguration")
case .kernelNotSpecified:
return NSLocalizedString("A valid kernel image must be specified.", comment: "UTMAppleConfiguration")
case .hardwareModelInvalid:
return NSLocalizedString("This virtual machine contains an invalid hardware model. The configuration may be corrupted or is outdated.", comment: "UTMAppleConfiguration")
case .rosettaNotSupported:
return NSLocalizedString("Rosetta is not supported on the current host machine.", comment: "UTMAppleConfiguration")
case .featureNotSupported:
return NSLocalizedString("The host operating system needs to be updated to support one or more features requested by the guest.", comment: "UTMAppleConfiguration")
case .secureBootEnrollmentFailed:
return NSLocalizedString("Failed to set up Secure Boot for this virtual machine.", comment: "UTMAppleConfiguration")
case .efiVariablesInaccessible:
return NSLocalizedString("The UEFI variables of this virtual machine could not be accessed.", comment: "UTMAppleConfiguration")
}
}
}
// MARK: - Public accessors
@MainActor extension UTMAppleConfiguration {
var information: UTMConfigurationInfo {
get {
_information
}
set {
_information = newValue
}
}
var system: UTMAppleConfigurationSystem {
get {
_system
}
set {
_system = newValue
}
}
var virtualization: UTMAppleConfigurationVirtualization {
get {
_virtualization
}
set {
_virtualization = newValue
}
}
var sharedDirectories: [UTMAppleConfigurationSharedDirectory] {
get {
_sharedDirectories
}
set {
_sharedDirectories = newValue
}
}
var sharedDirectoriesPublisher: Published<[UTMAppleConfigurationSharedDirectory]>.Publisher {
get {
$_sharedDirectories
}
}
var displays: [UTMAppleConfigurationDisplay] {
get {
_displays
}
set {
_displays = newValue
}
}
var drives: [UTMAppleConfigurationDrive] {
get {
_drives
}
set {
_drives = newValue
}
}
var networks: [UTMAppleConfigurationNetwork] {
get {
_networks
}
set {
_networks = newValue
}
}
var serials: [UTMAppleConfigurationSerial] {
get {
_serials
}
set {
_serials = newValue
}
}
}
// MARK: - Conversion of old config format
extension UTMAppleConfiguration {
convenience init(migrating oldConfig: UTMLegacyAppleConfiguration, dataURL: URL) {
self.init()
_information = .init(migrating: oldConfig, dataURL: dataURL)
_system = .init(migrating: oldConfig)
_virtualization = .init(migrating: oldConfig)
_sharedDirectories = oldConfig.sharedDirectories.map { .init(migrating: $0) }
#if arch(arm64)
_displays = oldConfig.displays.map { .init(migrating: $0) }
#endif
_drives = oldConfig.diskImages.map { .init(migrating: $0) }
_networks = oldConfig.networkDevices.map { .init(migrating: $0) }
if oldConfig.isConsoleDisplay {
var serial = UTMAppleConfigurationSerial()
serial.terminal = .init(migrating: oldConfig)
_serials = [serial]
} else if oldConfig.isSerialEnabled {
var serial = UTMAppleConfigurationSerial()
serial.mode = .ptty
_serials = [serial]
}
}
}
// MARK: - Creating Apple config
@available(iOS, unavailable, message: "Apple Virtualization not available on iOS")
@MainActor extension UTMAppleConfiguration {
/// Point the firmware variable stores at copies so a disposable run cannot change the originals
@available(macOS 27, *)
private func redirectFirmwareStorage(of vzconfig: VZVirtualMachineConfiguration, to directoryURL: URL) throws {
let fileManager = FileManager.default
if let efi = vzconfig.bootLoader as? VZEFIBootLoader, let variableStore = efi.variableStore {
let copyURL = directoryURL.appendingPathComponent(variableStore.url.lastPathComponent)
try fileManager.copyItem(at: variableStore.url, to: copyURL)
efi.variableStore = VZEFIVariableStore(url: copyURL)
}
#if arch(arm64)
if let platform = vzconfig.platform as? VZMacPlatformConfiguration, let auxiliaryStorage = platform.auxiliaryStorage {
let copyURL = directoryURL.appendingPathComponent(auxiliaryStorage.url.lastPathComponent)
try fileManager.copyItem(at: auxiliaryStorage.url, to: copyURL)
platform.auxiliaryStorage = VZMacAuxiliaryStorage(url: copyURL)
}
#endif
}
/// - Parameter disposableOverlayDirectoryURL: If set, guest writes go to ephemeral layers in this directory and nothing in the package is modified (macOS 27+)
func appleVZConfiguration(forValidation: Bool = false, disposableOverlayDirectoryURL: URL? = nil) throws -> VZVirtualMachineConfiguration {
let vzconfig = VZVirtualMachineConfiguration()
try system.fillVZConfiguration(vzconfig)
if #available(macOS 27, *), let disposableOverlayDirectoryURL = disposableOverlayDirectoryURL {
try redirectFirmwareStorage(of: vzconfig, to: disposableOverlayDirectoryURL)
}
if !sharedDirectories.isEmpty {
let fsConfig = VZVirtioFileSystemDeviceConfiguration(tag: shareDirectoryTag)
fsConfig.share = UTMAppleConfigurationSharedDirectory.makeDirectoryShare(from: sharedDirectories)
vzconfig.directorySharingDevices.append(fsConfig)
}
if !forValidation {
vzconfig.storageDevices = try drives.compactMap { drive in
guard let attachment = try drive.vzDiskImage(useFsWorkAround: system.boot.operatingSystem == .linux, disposableOverlayDirectoryURL: disposableOverlayDirectoryURL) else {
return nil
}
if #available(macOS 13, *), drive.isExternal {
if #available(macOS 15, *) {
return nil // we will handle removable drives in `UTMAppleVirtualMachine`
} else {
return VZUSBMassStorageDeviceConfiguration(attachment: attachment)
}
} else if #available(macOS 14, *), drive.isNvme, system.boot.operatingSystem == .linux {
return VZNVMExpressControllerDeviceConfiguration(attachment: attachment)
} else {
let device = VZVirtioBlockDeviceConfiguration(attachment: attachment)
if #available(macOS 12.3, *) {
device.blockDeviceIdentifier = drive.serial
}
return device
}
}
}
vzconfig.networkDevices.append(contentsOf: try networks.compactMap({ try $0.vzNetworking(forValidation: forValidation) }))
vzconfig.serialPorts.append(contentsOf: serials.compactMap({ $0.vzSerial() }))
// add remaining devices
try virtualization.fillVZConfiguration(vzconfig, isMacOSGuest: system.boot.operatingSystem == .macOS)
#if arch(arm64)
if system.boot.operatingSystem == .macOS {
let graphics = VZMacGraphicsDeviceConfiguration()
graphics.displays = displays.map({ display in
display.vzMacDisplay()
})
if graphics.displays.count > 0 {
vzconfig.graphicsDevices = [graphics]
}
}
#endif
if #available(macOS 13, *), system.boot.operatingSystem != .macOS {
let graphics = VZVirtioGraphicsDeviceConfiguration()
graphics.scanouts = displays.map({ display in
display.vzVirtioDisplay()
})
if graphics.scanouts.count > 0 {
vzconfig.graphicsDevices = [graphics]
}
} else if system.boot.operatingSystem != .macOS && !displays.isEmpty {
throw UTMAppleConfigurationError.featureNotSupported
}
if #available(macOS 15, *) {
vzconfig.usbControllers = [VZXHCIControllerConfiguration()]
}
if #available(macOS 27, *) {
// names the virtual machine in system prompts such as USB device access
vzconfig.label = information.name.vzLabel
}
return vzconfig
}
var shareDirectoryTag: String {
if #available(macOS 13, *), system.boot.operatingSystem == .macOS {
return VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
} else {
return "share"
}
}
}
private extension String {
/// Label accepted by `VZVirtualMachineConfiguration`: non-empty and at most 64 characters
var vzLabel: String? {
var label = trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty else {
return nil
}
while label.utf16.count > 64 {
label.removeLast()
}
return label
}
}
// MARK: - Saving data
@available(iOS, unavailable, message: "Apple Virtualization not available on iOS")
@MainActor extension UTMAppleConfiguration {
func prepareSave(for packageURL: URL) async throws {
try await virtualization.prepareSave(for: packageURL)
}
func saveData(to dataURL: URL) async throws -> [URL] {
var existingDataURLs = [URL]()
existingDataURLs += try await _information.saveData(to: dataURL)
existingDataURLs += try await _system.boot.saveData(to: dataURL)
#if arch(arm64)
if system.macPlatform != nil {
existingDataURLs += try await _system.macPlatform!.saveData(to: dataURL)
}
#endif
// validate before we copy and create drive images
try appleVZConfiguration(forValidation: true).validate()
for i in 0..<drives.count {
existingDataURLs += try await _drives[i].saveData(to: dataURL)
}
// what the snapshots hold sits beside the files above and is named after them
existingDataURLs += UTMAppleSnapshotFiles.preservedDataURLs(for: existingDataURLs, in: dataURL.deletingLastPathComponent())
return existingDataURLs
}
}