diff --git a/Configuration/UTMAppleConfiguration.swift b/Configuration/UTMAppleConfiguration.swift index 81a275bc..30903844 100644 --- a/Configuration/UTMAppleConfiguration.swift +++ b/Configuration/UTMAppleConfiguration.swift @@ -384,6 +384,9 @@ private extension String { 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 } } diff --git a/Services/UTMAppleDiskImage.swift b/Services/UTMAppleDiskImage.swift index 2a53b40e..7e17a436 100644 --- a/Services/UTMAppleDiskImage.swift +++ b/Services/UTMAppleDiskImage.swift @@ -337,6 +337,33 @@ extension UTMAppleDiskImage { try removeUnusedLayers(of: imageURL) } + /// Layers of an image that are still needed, given the snapshots that are still wanted. + /// + /// A layer is needed when it is the one being written to, when it belongs to one of `names`, + /// or when it lies under a layer that is needed. Anything else is left over and may go. + /// - Parameters: + /// - imageURL: Location of the image + /// - names: Names of the snapshots to keep + static func preservedLayerURLs(of imageURL: URL, for names: Set) throws -> [URL] { + let fileURLs = layerFileURLs(of: imageURL) + let layers = try layers(of: imageURL) + // a layer that could not be read, such as the one a running VM writes to, may be needed + // by any of the others and is kept along with all of them + guard layers.count == fileURLs.count else { + return fileURLs + } + var preserved = Set() + for top in layers where top.isCurrent || names.contains(top.name) { + guard let chain = try? chain(endingAt: top, in: layers) else { + return fileURLs + } + for layer in chain { + preserved.insert(layer.url) + } + } + return Array(preserved) + } + /// Delete the layers of deleted snapshots that no other layer needs anymore private static func removeUnusedLayers(of imageURL: URL) throws { var layers = try layers(of: imageURL) diff --git a/Services/UTMAppleSnapshotBackend.swift b/Services/UTMAppleSnapshotBackend.swift new file mode 100644 index 00000000..971596c9 --- /dev/null +++ b/Services/UTMAppleSnapshotBackend.swift @@ -0,0 +1,256 @@ +// +// Copyright © 2026 Turing Software, LLC. 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 + +extension UTMAppleVirtualMachine { + /// Images that take part in a snapshot + @MainActor var snapshotImageURLs: [URL] { + config.drives.compactMap { drive in + drive.isReadOnly || drive.isExternal ? nil : drive.imageURL + } + } + + /// Small files the guest writes to that are not disks, such as the variables of the firmware + @MainActor var snapshotAuxiliaryURLs: [URL] { + [config.system.boot.efiVariableStorageURL, config.system.macPlatform?.auxiliaryStorageURL].compactMap { url in + url.flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil } + } + } +} + +/// Which files a bundle has to keep for the snapshots of an Apple VM. +/// +/// A bundle only keeps the files its configuration names, and what a snapshot holds is named +/// after the file it belongs to rather than being named by the configuration. +enum UTMAppleSnapshotFiles { + /// Files beside `baseURLs` that the snapshots recorded in the bundle still need. + /// + /// Everything else beside them is left over — from a snapshot that was deleted, or from a + /// tool that is no longer around — and is left out so that it is cleaned up with the rest. + /// - Parameters: + /// - baseURLs: Files the configuration keeps + /// - bundleURL: Bundle of the VM + static func preservedDataURLs(for baseURLs: [URL], in bundleURL: URL) -> [URL] { + // without the manifest nothing can be ruled out, and snapshots it does not list yet are + // adopted from these files the next time the snapshots are read + guard let manifest = (try? UTMSnapshotManifest.load(from: bundleURL)) ?? nil else { + return baseURLs.flatMap { namedAfter($0) } + } + var identifiers = Set(manifest.snapshots.map { $0.backendIdentifier }) + if let suspendIdentifier = manifest.suspendIdentifier { + identifiers.insert(suspendIdentifier) + } + let fileManager = FileManager.default + var preserved = [URL]() + for baseURL in baseURLs { + // the saved state and the firmware of a snapshot are copies named after the original + for identifier in identifiers { + let url = baseURL.appendingPathExtension(identifier) + if fileManager.fileExists(atPath: url.path) { + preserved.append(url) + } + } + if #available(macOS 27, *), let layerURLs = try? UTMAppleDiskImage.preservedLayerURLs(of: baseURL, for: identifiers) { + preserved += layerURLs + } else { + // the layers cannot be read, so keep all of them rather than lose one + preserved += namedAfter(baseURL).filter { $0.pathExtension == "asif" } + } + } + return preserved + } + + /// Files beside `baseURL` whose names start with its own, which is how snapshots name theirs + private static func namedAfter(_ baseURL: URL) -> [URL] { + let prefix = baseURL.lastPathComponent + "." + let siblings = (try? FileManager.default.contentsOfDirectory(at: baseURL.deletingLastPathComponent(), includingPropertiesForKeys: nil)) ?? [] + return siblings.filter { $0.lastPathComponent.hasPrefix(prefix) } + } +} + +/// Snapshots of a VM using Apple Virtualization. +/// +/// A snapshot spans every drive. The disks are kept as a layer of each image (see +/// `UTMAppleDiskImage`), while the running state and the files in `auxiliaryURLs` are small +/// enough to be kept as copies named after the snapshot. Virtualization cannot change the layers of a +/// disk that is attached, so the current state can only be saved or replaced while the VM is +/// not running. Everything else works either way. +@available(macOS 27, *) +struct UTMAppleSnapshotBackend: UTMSnapshotBackend { + let imageURLs: [URL] + + let isInventoryExact = true + + /// Files that are not disks but still part of the state of the guest + let auxiliaryURLs: [URL] + + /// File the VM is suspended to + let savedStateURL: URL? + + let isRunning: Bool + + /// Kept from starting while its files change + var vm: UTMAppleVirtualMachine? = nil + + /// There is only one file to suspend to which is never a snapshot by itself + let defaultSuspendIdentifier = "suspend" + + func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool { + // the layers below a disk that is attached have to stay the same + !isRunning || operation == .delete + } + + private var fileManager: FileManager { + FileManager.default + } + + private func snapshotStateURL(for identifier: String) -> URL? { + savedStateURL?.appendingPathExtension(identifier) + } + + /// Copy beside `url` for `replaceItemAt`, which consumes what it puts in place + private func copyToTemporary(_ url: URL) throws -> URL { + let temporaryURL = url.appendingPathExtension(UUID().uuidString) + try fileManager.copyItem(at: url, to: temporaryURL) + return temporaryURL + } + + private func size(of url: URL) -> Int64 { + let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]) + return Int64(values?.totalFileAllocatedSize ?? 0) + } + + func entries() async throws -> [UTMSnapshotBackendEntry] { + var entries = [String: UTMSnapshotBackendEntry]() + var imageCounts = [String: Int]() + for imageURL in imageURLs { + let layers = try UTMAppleDiskImage.layers(of: imageURL) + for layer in layers where !layer.isCurrent && !layer.isUnlinked { + // skip the layers of deleted snapshots to find the one this was taken from + var parent = layers.first { $0.uuid == layer.parentUUID } + while let unlinked = parent, unlinked.isUnlinked { + parent = layers.first { $0.uuid == unlinked.parentUUID } + } + let date = (try? layer.url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? Date() + let previous = entries[layer.name] + entries[layer.name] = UTMSnapshotBackendEntry(identifier: layer.name, + date: max(date, previous?.date ?? .distantPast), + size: size(of: layer.url) + (previous?.size ?? 0), + hasState: false, + parentIdentifier: previous?.parentIdentifier ?? parent?.name) + imageCounts[layer.name, default: 0] += 1 + } + } + return entries.values.map { entry in + var entry = entry + entry.isComplete = imageCounts[entry.identifier] == imageURLs.count + guard let stateURL = snapshotStateURL(for: entry.identifier), fileManager.fileExists(atPath: stateURL.path) else { + return entry + } + return UTMSnapshotBackendEntry(identifier: entry.identifier, + date: entry.date, + size: entry.size + size(of: stateURL), + hasState: true, + isComplete: entry.isComplete, + parentIdentifier: entry.parentIdentifier) + } + } + + func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry { + let identifier = UUID().uuidString + try await changingFiles(as: .saving) { + do { + for imageURL in imageURLs { + try UTMAppleDiskImage.createSnapshot(of: imageURL, name: identifier) + } + for auxiliaryURL in auxiliaryURLs { + try fileManager.copyItem(at: auxiliaryURL, to: auxiliaryURL.appendingPathExtension(identifier)) + } + if suspendIdentifier != nil, let savedStateURL = savedStateURL, let stateURL = snapshotStateURL(for: identifier) { + // the VM still resumes from its own copy + try fileManager.copyItem(at: savedStateURL, to: stateURL) + } + } catch { + try? await delete(identifier: identifier) + throw error + } + } + guard let entry = try await entries().first(where: { $0.identifier == identifier }) else { + throw UTMSnapshotError.notFound(identifier) + } + return entry + } + + func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool { + guard entry.isComplete else { + throw UTMSnapshotError.incomplete(entry.identifier) + } + try await changingFiles(as: .restoring) { + // every drive is restored or none is + for imageURL in imageURLs { + try UTMAppleDiskImage.validateSnapshot(of: imageURL, name: entry.identifier) + } + for imageURL in imageURLs { + try UTMAppleDiskImage.restoreSnapshot(of: imageURL, name: entry.identifier) + // what the VM was suspended to stops matching the drives once one is restored + if let savedStateURL = savedStateURL, fileManager.fileExists(atPath: savedStateURL.path) { + try? fileManager.removeItem(at: savedStateURL) + } + } + for auxiliaryURL in auxiliaryURLs { + let snapshotURL = auxiliaryURL.appendingPathExtension(entry.identifier) + if fileManager.fileExists(atPath: snapshotURL.path) { + // the original stays until the copy is in place so a failure cannot lose it + _ = try fileManager.replaceItemAt(auxiliaryURL, withItemAt: try copyToTemporary(snapshotURL)) + } + } + if let savedStateURL = savedStateURL, entry.hasState, let stateURL = snapshotStateURL(for: entry.identifier) { + try fileManager.copyItem(at: stateURL, to: savedStateURL) + } + } + return savedStateURL != nil && entry.hasState + } + + private func changingFiles(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws { + if let vm = vm { + try await vm.changingFiles(as: state, body) + } else { + try await body() + } + } + + func delete(identifier: String) async throws { + var isFound = false + for imageURL in imageURLs { + do { + try UTMAppleDiskImage.deleteSnapshot(of: imageURL, name: identifier) + isFound = true + } catch UTMAppleDiskImageError.snapshotNotFound(_) { + // a drive added after the snapshot was created + } + } + for auxiliaryURL in auxiliaryURLs { + try? fileManager.removeItem(at: auxiliaryURL.appendingPathExtension(identifier)) + } + if let stateURL = snapshotStateURL(for: identifier) { + try? fileManager.removeItem(at: stateURL) + } + guard isFound else { + throw UTMSnapshotError.notFound(identifier) + } + } +} diff --git a/Services/UTMAppleVirtualMachine.swift b/Services/UTMAppleVirtualMachine.swift index 5cb37d05..18586a3e 100644 --- a/Services/UTMAppleVirtualMachine.swift +++ b/Services/UTMAppleVirtualMachine.swift @@ -287,6 +287,8 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { state = .stopped if !isDisposible { try? await deleteSnapshot() + // the layers of a snapshot below the disks may be gone + try? await UTMSnapshotService.identifyOrphanedSnapshots(on: self) } throw error } @@ -553,9 +555,48 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { } await registryEntry.setIsSuspended(false) await registryEntry.setConnectedUsbDevices([]) - try FileManager.default.removeItem(at: vmSavedStateURL) + // a state that is gone already is still let go of below + if FileManager.default.fileExists(atPath: vmSavedStateURL.path) { + try FileManager.default.removeItem(at: vmSavedStateURL) + } + if #available(macOS 27, *) { + try? await releaseSuspendSnapshot(savedStateURL: vmSavedStateURL) + } try? updateLastModified() } + + /// Keep the VM from starting while the files of its snapshots change, which it would otherwise + /// attach halfway through + @available(macOS 27, *) + func changingFiles(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws { + guard self.state == .stopped else { + throw UTMAppleVirtualMachineError.operationNotAvailable + } + self.state = state + defer { + self.state = .stopped + } + try await body() + } + + /// The saved state may have been a copy of a snapshot that was only kept to resume from + @available(macOS 27, *) + private func releaseSuspendSnapshot(savedStateURL: URL) async throws { + let staleIdentifiers = try UTMSnapshotManifest.releaseSuspendState(in: pathUrl, default: nil) + guard !staleIdentifiers.isEmpty else { + return + } + let backend = UTMAppleSnapshotBackend(imageURLs: await snapshotImageURLs, auxiliaryURLs: await snapshotAuxiliaryURLs, savedStateURL: savedStateURL, isRunning: state != .stopped) + // one that cannot go now stays listed and goes the next time + for identifier in staleIdentifiers { + do { + try await backend.delete(identifier: identifier) + } catch UTMSnapshotError.notFound(_) { + // already gone + } + try UTMSnapshotManifest.forgetState(identifier, in: pathUrl) + } + } #if arch(arm64) @available(macOS 14, *) diff --git a/Services/UTMSnapshotService.swift b/Services/UTMSnapshotService.swift index bd0b5279..66d42579 100644 --- a/Services/UTMSnapshotService.swift +++ b/Services/UTMSnapshotService.swift @@ -43,6 +43,8 @@ enum UTMSnapshotService { #if os(macOS) if vm is UTMQemuVirtualMachine { return true + } else if #available(macOS 27, *), vm is UTMAppleVirtualMachine { + return true } #endif // qemu-img is needed for a VM that is not running and is only part of the macOS app @@ -379,6 +381,18 @@ enum UTMSnapshotService { default: throw UTMSnapshotError.invalidVmState } } + #if os(macOS) + if #available(macOS 27, *), let apple = vm as? UTMAppleVirtualMachine { + guard [.started, .paused, .stopped].contains(vm.state) else { + throw UTMSnapshotError.invalidVmState + } + return UTMAppleSnapshotBackend(imageURLs: apple.snapshotImageURLs, + auxiliaryURLs: apple.snapshotAuxiliaryURLs, + savedStateURL: apple.config.system.boot.vmSavedStateURL, + isRunning: vm.state != .stopped, + vm: apple) + } + #endif throw UTMSnapshotError.notSupported } diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 087dd8c7..9bda9502 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -687,6 +687,7 @@ CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */; }; CE7A1C062EFA0A01001671E9 /* AccessoryAccess.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */; }; + 1C5910D866A20395225768B9 /* UTMAppleSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */; }; CE1D15AD2F6B100100D15C01 /* DiskImageKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; CE6804852E4E5D84001671E9 /* UTMScriptingInputImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */; }; CE68E5442E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; }; @@ -2060,6 +2061,7 @@ CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleUSBManager.swift; sourceTree = ""; }; CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AccessoryAccess.framework; path = System/Library/Frameworks/AccessoryAccess.framework; sourceTree = SDKROOT; }; CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleDiskImage.swift; sourceTree = ""; }; + 0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleSnapshotBackend.swift; sourceTree = ""; }; CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DiskImageKit.framework; path = System/Library/Frameworks/DiskImageKit.framework; sourceTree = SDKROOT; }; CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingInputImpl.swift; sourceTree = ""; }; CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMKeyboardShortcutsView.swift; sourceTree = ""; }; @@ -3023,6 +3025,7 @@ CE7A1C012EFA0A01001671E9 /* UTMAppleUSBDevice.swift */, CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */, CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */, + 0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */, CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */, CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */, 4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */, @@ -4207,6 +4210,7 @@ CE7A1C022EFA0A01001671E9 /* UTMAppleUSBDevice.swift in Sources */, CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */, CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */, + 1C5910D866A20395225768B9 /* UTMAppleSnapshotBackend.swift in Sources */, CEF0306F26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */, 83034C0926AB630F006B4BAF /* UTMPendingVMView.swift in Sources */, 84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,