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/Configuration/UTMAppleConfigurationDrive.swift b/Configuration/UTMAppleConfigurationDrive.swift index ff50b200..8c49ac1c 100644 --- a/Configuration/UTMAppleConfigurationDrive.swift +++ b/Configuration/UTMAppleConfigurationDrive.swift @@ -111,10 +111,15 @@ struct UTMAppleConfigurationDrive: UTMConfigurationDrive { } // Use cached caching mode for virtio drive to prevent fs corruption on linux when possible let useCachedMode = !isNvme && useFsWorkAround - if #available(macOS 27, *), let overlayDirectoryURL = disposableOverlayDirectoryURL, !isReadOnly { - let overlayURL = overlayDirectoryURL.appendingPathComponent("\(id).asif") - return try UTMAppleDiskImage.attachment(for: imageURL, ephemeralOverlayAt: overlayURL, cachingMode: useCachedMode ? .cached : .automatic, synchronizationMode: .full) - } else if useCachedMode { + if #available(macOS 27, *) { + let overlayURL = isReadOnly ? nil : disposableOverlayDirectoryURL?.appendingPathComponent("\(id).asif") + if let attachment = try UTMAppleDiskImage.attachment(for: imageURL, includesLayers: !isExternal, ephemeralOverlayAt: overlayURL, isReadOnly: isReadOnly, cachingMode: useCachedMode ? .cached : .automatic, synchronizationMode: .full) { + return attachment + } + } else if !isExternal && !UTMAppleDiskImage.layerFileURLs(of: imageURL).isEmpty { + throw UTMAppleDiskImageError.snapshotsRequireNewerOS + } + if useCachedMode { return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly, cachingMode: .cached, synchronizationMode: .full) } else { return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly) diff --git a/Configuration/UTMQemuConfiguration+Arguments.swift b/Configuration/UTMQemuConfiguration+Arguments.swift index 7e154bd4..061dca1b 100644 --- a/Configuration/UTMQemuConfiguration+Arguments.swift +++ b/Configuration/UTMQemuConfiguration+Arguments.swift @@ -652,7 +652,7 @@ import Virtualization // for getting network interfaces f() } - private var hasCustomBios: Bool { + var hasCustomBios: Bool { for drive in drives { if drive.imageType == .disk || drive.imageType == .cd { if drive.interface == .pflash { diff --git a/Configuration/UTMSnapshotManifest.swift b/Configuration/UTMSnapshotManifest.swift new file mode 100644 index 00000000..8481dbb7 --- /dev/null +++ b/Configuration/UTMSnapshotManifest.swift @@ -0,0 +1,343 @@ +// +// 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 + +/// Everything UTM knows about the snapshots of a VM, stored as `snapshots.plist` in the bundle. +/// +/// The backend (QEMU image, Apple disk image) only stores the state of a snapshot. Each entry +/// here links to that state through `backendIdentifier` and adds what the backend cannot hold. +struct UTMSnapshotManifest: Codable, Equatable { + static let fileName = "snapshots.plist" + + /// Directory in the bundle holding the screenshots referenced by the entries + static let screenshotsDirectoryName = "Snapshots" + + struct Snapshot: Codable, Identifiable, Equatable { + var id: UUID = UUID() + + /// User-editable display name + var title: String + + /// Snapshot the current state was based on when this one was created, `nil` if there was none + var parentID: UUID? + + var dateCreated: Date + + var dateModified: Date + + /// Bytes occupied in the backend + var size: Int64 + + /// Includes the running state of the VM and not just the disks + var hasState: Bool + + /// Identifies the state in the backend + var backendIdentifier: String + + /// File in the screenshots directory + var screenshotName: String? + + /// The backend no longer has the state this entry links to + var isOrphaned: Bool = false + + /// The state does not cover every drive, such as after one was added, so it cannot be restored + var isIncomplete: Bool = false + + private enum CodingKeys: String, CodingKey { + case id = "Identifier" + case title = "Title" + case parentID = "Parent" + case dateCreated = "Created" + case dateModified = "Modified" + case size = "Size" + case hasState = "HasState" + case backendIdentifier = "BackendIdentifier" + case screenshotName = "Screenshot" + case isOrphaned = "Orphaned" + case isIncomplete = "Incomplete" + } + + init(title: String, parentID: UUID?, date: Date, size: Int64, hasState: Bool, backendIdentifier: String, screenshotName: String? = nil) { + self.title = title + self.parentID = parentID + self.dateCreated = date + self.dateModified = date + self.size = size + self.hasState = hasState + self.backendIdentifier = backendIdentifier + self.screenshotName = screenshotName + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(UUID.self, forKey: .id) + title = try values.decode(String.self, forKey: .title) + parentID = try values.decodeIfPresent(UUID.self, forKey: .parentID) + dateCreated = try values.decode(Date.self, forKey: .dateCreated) + dateModified = try values.decode(Date.self, forKey: .dateModified) + size = try values.decode(Int64.self, forKey: .size) + hasState = try values.decode(Bool.self, forKey: .hasState) + backendIdentifier = try values.decode(String.self, forKey: .backendIdentifier) + screenshotName = try values.decodeIfPresent(String.self, forKey: .screenshotName) + isOrphaned = try values.decodeIfPresent(Bool.self, forKey: .isOrphaned) ?? false + isIncomplete = try values.decodeIfPresent(Bool.self, forKey: .isIncomplete) ?? false + } + + func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(id, forKey: .id) + try values.encode(title, forKey: .title) + try values.encodeIfPresent(parentID, forKey: .parentID) + try values.encode(dateCreated, forKey: .dateCreated) + try values.encode(dateModified, forKey: .dateModified) + try values.encode(size, forKey: .size) + try values.encode(hasState, forKey: .hasState) + try values.encode(backendIdentifier, forKey: .backendIdentifier) + try values.encodeIfPresent(screenshotName, forKey: .screenshotName) + if isOrphaned { + try values.encode(isOrphaned, forKey: .isOrphaned) + } + if isIncomplete { + try values.encode(isIncomplete, forKey: .isIncomplete) + } + } + } + + private(set) var version: Int = 1 + + /// Newest first + var snapshots: [Snapshot] = [] + + /// Snapshot the current state descends from, `nil` if there is none + var currentParentID: UUID? + + /// Backend state the VM resumes from the next time it starts while it is marked as suspended + /// + /// This may be the state of a snapshot. Otherwise it is only used for suspending. + var suspendIdentifier: String? + + /// Snapshots already in the backend were added, which is done once for the benefit of those + /// made by other tools. The manifest can exist before this because suspending creates it. + var isBackendImported: Bool = false + + /// States in the backend that nothing needs anymore but that could not be deleted yet + var staleIdentifiers: [String] = [] + + private enum CodingKeys: String, CodingKey { + case version = "Version" + case snapshots = "Snapshots" + case currentParentID = "CurrentParent" + case suspendIdentifier = "SuspendIdentifier" + case isBackendImported = "BackendImported" + case staleIdentifiers = "Stale" + } + + init() { + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + version = try values.decode(Int.self, forKey: .version) + snapshots = try values.decode([Snapshot].self, forKey: .snapshots) + currentParentID = try values.decodeIfPresent(UUID.self, forKey: .currentParentID) + suspendIdentifier = try values.decodeIfPresent(String.self, forKey: .suspendIdentifier) + isBackendImported = try values.decodeIfPresent(Bool.self, forKey: .isBackendImported) ?? false + staleIdentifiers = try values.decodeIfPresent([String].self, forKey: .staleIdentifiers) ?? [] + } + + func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(version, forKey: .version) + try values.encode(snapshots, forKey: .snapshots) + try values.encodeIfPresent(currentParentID, forKey: .currentParentID) + try values.encodeIfPresent(suspendIdentifier, forKey: .suspendIdentifier) + try values.encode(isBackendImported, forKey: .isBackendImported) + if !staleIdentifiers.isEmpty { + try values.encode(staleIdentifiers, forKey: .staleIdentifiers) + } + } +} + +// MARK: - Links + +extension UTMSnapshotManifest { + subscript(id: UUID) -> Snapshot? { + get { + snapshots.first { $0.id == id } + } + + set { + if let index = snapshots.firstIndex(where: { $0.id == id }) { + if let newValue = newValue { + snapshots[index] = newValue + } else { + snapshots.remove(at: index) + } + } else if let newValue = newValue { + insert(newValue) + } + } + } + + mutating func insert(_ snapshot: Snapshot) { + snapshots.append(snapshot) + snapshots.sort { $0.dateCreated > $1.dateCreated } + } + + /// Removes an entry and hands its children to its parent so the remaining lineage stays linked. + mutating func remove(_ id: UUID) { + guard let snapshot = self[id] else { + return + } + for index in snapshots.indices where snapshots[index].parentID == id { + snapshots[index].parentID = snapshot.parentID + } + if currentParentID == id { + currentParentID = snapshot.parentID + } + self[id] = nil + } + + /// The state in the backend is still needed by an entry or for resuming + func isReferenced(backendIdentifier: String) -> Bool { + suspendIdentifier == backendIdentifier || snapshots.contains { $0.backendIdentifier == backendIdentifier } + } + + /// Remember to delete a state from the backend unless something still needs it + mutating func markStale(_ backendIdentifier: String) { + if !isReferenced(backendIdentifier: backendIdentifier) && !staleIdentifiers.contains(backendIdentifier) { + staleIdentifiers.append(backendIdentifier) + } + } + + /// Resume from `backendIdentifier` from now on, or from nothing + mutating func setSuspendState(_ backendIdentifier: String?) { + let previous = suspendIdentifier + suspendIdentifier = backendIdentifier + if let previous = previous, previous != backendIdentifier { + markStale(previous) + } + } +} + +// MARK: - Suspend state + +/// How a VM keeps track of the state it resumes from, the only part of the manifest that a VM +/// changes itself. Everything else goes through `UTMSnapshotService`. +/// +/// A state that is let go of stays listed as stale until it has been deleted, so that one that +/// cannot be deleted right away is tried again the next time. +extension UTMSnapshotManifest { + /// State the VM in a bundle resumes from, if the manifest names one + static func suspendState(in bundleURL: URL) -> String? { + ((try? load(from: bundleURL)) ?? nil)?.suspendIdentifier + } + + /// Resume from `backendIdentifier` from now on. + /// - Returns: States to delete, which are forgotten with `forgetState(_:in:)` once they are gone + static func replaceSuspendState(in bundleURL: URL, with backendIdentifier: String) throws -> [String] { + try update(in: bundleURL) { manifest in + manifest.setSuspendState(backendIdentifier) + return manifest.staleIdentifiers + } + } + + /// Stop resuming from any state. + /// - Parameter defaultIdentifier: State of a VM that was suspended before the manifest named it + /// - Returns: States to delete, which are forgotten with `forgetState(_:in:)` once they are gone + static func releaseSuspendState(in bundleURL: URL, default defaultIdentifier: String?) throws -> [String] { + try update(in: bundleURL) { manifest in + if manifest.suspendIdentifier == nil, let defaultIdentifier = defaultIdentifier { + manifest.suspendIdentifier = defaultIdentifier + } + manifest.setSuspendState(nil) + return manifest.staleIdentifiers + } + } + + /// A state that was to be deleted is gone + static func forgetState(_ backendIdentifier: String, in bundleURL: URL) throws { + try update(in: bundleURL) { manifest in + manifest.staleIdentifiers.removeAll { $0 == backendIdentifier } + } + } +} + +// MARK: - Storage + +extension UTMSnapshotManifest { + /// Serializes every read-modify-write of a manifest + private static let lock = NSLock() + + private static func withLock(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { + lock.unlock() + } + return try body() + } + + private static func fileURL(in bundleURL: URL) -> URL { + bundleURL.appendingPathComponent(fileName) + } + + static func screenshotsURL(in bundleURL: URL) -> URL { + bundleURL.appendingPathComponent(screenshotsDirectoryName, isDirectory: true) + } + + /// - Returns: `nil` if the bundle has no manifest yet + static func load(from bundleURL: URL) throws -> UTMSnapshotManifest? { + let fileURL = fileURL(in: bundleURL) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return nil + } + let data = try Data(contentsOf: fileURL) + return try PropertyListDecoder().decode(UTMSnapshotManifest.self, from: data) + } + + func save(to bundleURL: URL) throws { + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + try encoder.encode(self).write(to: Self.fileURL(in: bundleURL), options: .atomic) + } + + /// Posted with the bundle as its object whenever a manifest is saved + static let didChangeNotification = Notification.Name("UTMSnapshotManifestDidChange") + + /// Modify the manifest of a bundle, creating it if needed. + /// - Parameters: + /// - bundleURL: Bundle of the VM + /// - body: Changes to make, nothing is written if this throws + /// - Returns: Result of `body` + @discardableResult + static func update(in bundleURL: URL, _ body: (inout UTMSnapshotManifest) throws -> T) throws -> T { + let (result, isChanged) = try withLock { () -> (T, Bool) in + var manifest = try load(from: bundleURL) ?? UTMSnapshotManifest() + let original = manifest + let result = try body(&manifest) + if manifest != original { + try manifest.save(to: bundleURL) + } + return (result, manifest != original) + } + // observers may take the lock themselves + if isChanged { + NotificationCenter.default.post(name: didChangeNotification, object: bundleURL) + } + return result + } +} diff --git a/Platform/Shared/VMDetailsView.swift b/Platform/Shared/VMDetailsView.swift index 61f6360b..441aec44 100644 --- a/Platform/Shared/VMDetailsView.swift +++ b/Platform/Shared/VMDetailsView.swift @@ -30,6 +30,18 @@ struct VMDetailsView: View { private let regularScreenSizeClass: Bool = true #endif + private enum Tab: Hashable { + case details + case snapshots + } + + /// Scroll targets, the screenshot is scrolled away to make room for the snapshots + private enum ScrollAnchor: Hashable { + case screenshot + case tabs + } + + @State private var selectedTab: Tab = .details @State private var size: Int64 = 0 @State private var updateTask: Task? private let updateIpIntervalNs = UInt64(10 * 1_000_000_000) @@ -51,53 +63,28 @@ struct VMDetailsView: View { Spacer() } } else { - ScrollView { - Screenshot(vm: vm, large: regularScreenSizeClass) - let notes = vm.detailsNotes ?? "" - if regularScreenSizeClass && !notes.isEmpty { - HStack(alignment: .top) { - Details(vm: vm, sizeLabel: sizeLabel) - .frame(maxWidth: .infinity) - Text(notes) - .font(.body) - .frame(maxWidth: .infinity, alignment: .topLeading) - .fixedSize(horizontal: false, vertical: true) - .padding([.leading, .trailing]) - }.padding([.leading, .trailing]) - #if os(macOS) - if let appleVM = vm.wrapped as? UTMAppleVirtualMachine { - VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry) - .padding([.leading, .trailing, .bottom]) - } else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine { - VMRemovableDrivesView(vm: vm, config: qemuVM.config) - .padding([.leading, .trailing, .bottom]) - } + ScrollViewReader { scrollProxy in + ScrollView { + Screenshot(vm: vm, large: regularScreenSizeClass) + .id(ScrollAnchor.screenshot) + #if WITH_REMOTE // FIXME: implement remote feature + detailsPane #else - let qemuConfig = vm.config as! UTMQemuConfiguration - VMRemovableDrivesView(vm: vm, config: qemuConfig) - .padding([.leading, .trailing, .bottom]) + if let wrapped = vm.wrapped, UTMSnapshotService.isSupported(for: wrapped) { + tabs(scrollProxy: scrollProxy) + } else { + detailsPane + } #endif - } else { - VStack { - Details(vm: vm, sizeLabel: sizeLabel) - if !notes.isEmpty { - Text(notes) - .font(.body) - .fixedSize(horizontal: false, vertical: true) - } - #if os(macOS) - if let appleVM = vm.wrapped as? UTMAppleVirtualMachine { - VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry) - } else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine { - VMRemovableDrivesView(vm: vm, config: qemuVM.config) - } - #else - let qemuConfig = vm.config as! UTMQemuConfiguration - VMRemovableDrivesView(vm: vm, config: qemuConfig) - #endif - }.padding([.leading, .trailing, .bottom]) } - }.labelStyle(DetailsLabelStyle()) + #if !WITH_REMOTE + .onChange(of: selectedTab) { tab in + withAnimation { + scrollProxy.scrollTo(tab == .snapshots ? ScrollAnchor.tabs : ScrollAnchor.screenshot, anchor: .top) + } + } + #endif + } .modifier(VMOptionalNavigationTitleModifier(vm: vm)) .modifier(VMToolbarModifier(vm: vm, bottom: !regularScreenSizeClass)) .sheet(isPresented: $data.showSettingsModal) { @@ -128,6 +115,98 @@ struct VMDetailsView: View { } } + #if !WITH_REMOTE + /// Panes below the screenshot that is shared by all of them. + @ViewBuilder private func tabs(scrollProxy: ScrollViewProxy) -> some View { + let snapshotsPane = VMSnapshotsView(vm: vm) { id in + withAnimation { + scrollProxy.scrollTo(id) + } + } + #if os(macOS) + TabView(selection: $selectedTab) { + detailsPane + .padding(.top) + .tabItem { Text("Details") } + .tag(Tab.details) + snapshotsPane + .padding() + .tabItem { Text("Snapshots") } + .tag(Tab.snapshots) + }.padding([.leading, .trailing, .bottom]) + .padding(.top, 8) + .id(ScrollAnchor.tabs) + #else + Picker("View", selection: $selectedTab) { + Text("Details").tag(Tab.details) + Text("Snapshots").tag(Tab.snapshots) + }.pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 400) + .padding([.leading, .trailing, .top]) + .padding(.bottom, 8) + .id(ScrollAnchor.tabs) + switch selectedTab { + case .details: + detailsPane + case .snapshots: + snapshotsPane + .padding([.leading, .trailing, .bottom]) + } + #endif + } + #endif + + /// A single view, because a tab view makes a separate tab of every view it is given + @ViewBuilder private var detailsPane: some View { + VStack { + let notes = vm.detailsNotes ?? "" + if regularScreenSizeClass && !notes.isEmpty { + HStack(alignment: .top) { + Details(vm: vm, sizeLabel: sizeLabel) + .frame(maxWidth: .infinity) + Text(notes) + .font(.body) + .frame(maxWidth: .infinity, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + .padding([.leading, .trailing]) + }.padding([.leading, .trailing]) + #if os(macOS) + if let appleVM = vm.wrapped as? UTMAppleVirtualMachine { + VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry) + .padding([.leading, .trailing, .bottom]) + } else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine { + VMRemovableDrivesView(vm: vm, config: qemuVM.config) + .padding([.leading, .trailing, .bottom]) + } + #else + let qemuConfig = vm.config as! UTMQemuConfiguration + VMRemovableDrivesView(vm: vm, config: qemuConfig) + .padding([.leading, .trailing, .bottom]) + #endif + } else { + VStack { + Details(vm: vm, sizeLabel: sizeLabel) + if !notes.isEmpty { + Text(notes) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } + #if os(macOS) + if let appleVM = vm.wrapped as? UTMAppleVirtualMachine { + VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry) + } else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine { + VMRemovableDrivesView(vm: vm, config: qemuVM.config) + } + #else + let qemuConfig = vm.config as! UTMQemuConfiguration + VMRemovableDrivesView(vm: vm, config: qemuConfig) + #endif + }.padding([.leading, .trailing, .bottom]) + } + }.labelStyle(DetailsLabelStyle()) + } + #if !WITH_REMOTE private func clearGuestIPs() { guard let qemuVM = vm.wrapped as? UTMQemuVirtualMachine else { diff --git a/Platform/Shared/VMSnapshotsView.swift b/Platform/Shared/VMSnapshotsView.swift new file mode 100644 index 00000000..13eb0e48 --- /dev/null +++ b/Platform/Shared/VMSnapshotsView.swift @@ -0,0 +1,886 @@ +// +// 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 SwiftUI + +/// Timeline of the current state followed by every saved snapshot, newest first. +/// +/// Designed to be placed inside a parent scroll view. `scrollTo` is called with the +/// identifier of a row that should be made visible. +struct VMSnapshotsView: View { + @ObservedObject var vm: VMData + var scrollTo: (UUID) -> Void = { _ in } + + var body: some View { + SnapshotTimeline(vm: vm, list: VMSnapshotList.list(for: vm), scrollTo: scrollTo) + .id(vm.id) + } +} + +// MARK: - Timeline + +private struct SnapshotTimeline: View { + @ObservedObject var vm: VMData + @ObservedObject var list: VMSnapshotList + let scrollTo: (UUID) -> Void + @EnvironmentObject private var data: UTMData + + /// Focused snapshot, `nil` focuses the current state + @State private var selectedID: UUID? + @State private var renamingID: UUID? + @State private var confirmation: Confirmation? + /// Keyboard commands go to the selected snapshot while this is set + @FocusState private var isTimelineFocused: Bool + + /// Asked before anything that cannot be taken back + private enum Confirmation: Equatable { + case delete(UUID) + /// The current state is replaced by the snapshot + case restore(UUID) + /// The snapshot the current state is based on is replaced by the current state + case overwriteBase + case deleteSuspendState + + /// Snapshot it is about, `nil` if it is about the current state + var id: UUID? { + switch self { + case .delete(let id), .restore(let id): return id + case .overwriteBase, .deleteSuspendState: return nil + } + } + } + + /// Snapshot the current state started out from, if it still exists + private var baseSnapshot: VMSnapshot? { + list.snapshot(for: list.currentParentID) + } + + /// The suspended state can be deleted whether the VM is running or not, just not in between + private var isReady: Bool { + vm.state == .stopped || vm.state == .started || vm.state == .paused + } + + /// Measured because a regular size class can still be narrow, such as next to the sidebar + @State private var width: CGFloat? + + #if os(macOS) + private let minimumRegularWidth: CGFloat = 520 + private let isCompactSizeClass: Bool = false + #else + private let minimumRegularWidth: CGFloat = 660 + @Environment(\.horizontalSizeClass) private var horizontalSizeClass: UserInterfaceSizeClass? + + private var isCompactSizeClass: Bool { + horizontalSizeClass == .compact + } + #endif + + /// Row actions move below the text when there is no room to keep them on the trailing edge + private var isCompact: Bool { + if let width = width { + return width < minimumRegularWidth + } else { + return isCompactSizeClass + } + } + + /// How a row ties into the rail drawn along the leading edge. + private struct Link { + /// Parent is the next row so the rail continues into it + var connectsDown: Bool = false + /// Previous row is a child so the rail arrives from it + var connectsUp: Bool = false + /// Parent that is somewhere else in the list + var distantParent: VMSnapshot? + var isInLineage: Bool = false + var isUpInLineage: Bool = false + } + + /// Links for the current state (first) followed by each snapshot. + private var links: [Link] { + let snapshots = list.snapshots + let lineage = list.lineage(from: selectedID ?? list.currentParentID) + let parents = [list.currentParentID] + snapshots.map { $0.parentID } + let ids = [nil] + snapshots.map { Optional($0.id) } + var links = [Link](repeating: Link(), count: ids.count) + for index in links.indices { + if let id = ids[index] { + links[index].isInLineage = lineage.contains(id) + } else { + links[index].isInLineage = selectedID == nil + } + guard let parentID = parents[index] else { + continue + } + if index + 1 < ids.count && ids[index + 1] == parentID { + links[index].connectsDown = true + links[index + 1].connectsUp = true + links[index + 1].isUpInLineage = links[index].isInLineage + } else { + links[index].distantParent = list.snapshot(for: parentID) + } + } + return links + } + + var body: some View { + let links = links + VStack(alignment: .leading, spacing: 0) { + SnapshotRow(isCompact: isCompact, + isFocused: selectedID == nil, + isSelected: false, + rail: rail(for: links[0], hasState: nil), + distantParent: links[0].distantParent, + onSelect: { select(nil) }, + onSelectParent: selectParent) { + SnapshotThumbnail(image: vm.screenshotImage, isCompact: isCompact) + } title: { + Text("Current State") + .font(.headline) + } details: { + Text(currentStatus) + } actions: { + currentActions + } + .confirmationDialog("Confirm", isPresented: isConfirming(nil), presenting: confirmation) { confirmation in + Button("Cancel", role: .cancel) {} + switch confirmation { + case .overwriteBase: + Button("Overwrite", role: .destructive) { + overwriteBase() + } + case .deleteSuspendState: + Button("Delete", role: .destructive) { + deleteSuspendState() + } + case .delete(_), .restore(_): + EmptyView() + } + } message: { confirmation in + switch confirmation { + case .overwriteBase: + Text("Do you want to overwrite the snapshot “\(baseSnapshot?.title ?? "")” with the current state of the virtual machine? What it holds now will be lost.") + case .deleteSuspendState: + Text("Do you want to delete the suspended state? The virtual machine will start up normally and all unsaved data will be lost.") + case .delete(_), .restore(_): + EmptyView() + } + } + if list.snapshots.isEmpty { + emptyPlaceholder + } else { + // the rail is drawn by the rows so it is not interrupted by the separator + Divider() + .padding(.leading, SnapshotRail.width) + .padding(.vertical, 6) + .background(alignment: .leading) { + if links[0].connectsDown { + SnapshotRail.Segment(style: links[0].isInLineage ? .lineage : .normal) + .frame(width: SnapshotRail.width) + } + } + } + ForEach(Array(zip(list.snapshots, links.dropFirst())), id: \.0.id) { snapshot, link in + SnapshotRow(isCompact: isCompact, + isFocused: selectedID == snapshot.id, + isSelected: selectedID == snapshot.id, + rail: rail(for: link, hasState: snapshot.hasState), + distantParent: link.distantParent, + onSelect: { select(selectedID == snapshot.id ? nil : snapshot.id) }, + onSelectParent: selectParent) { + SnapshotThumbnail(image: snapshot.screenshot, isCompact: isCompact) + } title: { + HStack(alignment: .firstTextBaseline) { + SnapshotTitle(title: snapshot.title, isEditing: renamingID == snapshot.id) { title in + if let title = title { + perform { + try await list.rename(snapshot, to: title) + } + } + renamingID = nil + isTimelineFocused = selectedID != nil + } + if isCompact && snapshot.size > 0 { + Spacer(minLength: 8) + SnapshotDetails.size(of: snapshot) + .font(.subheadline) + .foregroundColor(.secondary) + .layoutPriority(1) + } + } + } details: { + if snapshot.isOrphaned { + Label("The data of this snapshot is missing.", systemImage: "exclamationmark.triangle.fill") + .labelStyle(.titleAndIcon) + .foregroundColor(.orange) + } else if snapshot.isIncomplete { + Label("This snapshot does not cover every drive.", systemImage: "exclamationmark.triangle.fill") + .labelStyle(.titleAndIcon) + .foregroundColor(.orange) + } + SnapshotDetails(snapshot: snapshot, isCompact: isCompact) + } actions: { + actions(for: snapshot) + } + .contextMenu { + menuActions(for: snapshot) + } + .confirmationDialog("Confirm", isPresented: isConfirming(snapshot.id), presenting: confirmation) { confirmation in + Button("Cancel", role: .cancel) {} + switch confirmation { + case .delete(_): + Button("Delete", role: .destructive) { + delete(snapshot) + } + case .restore(_): + Button("Restore", role: .destructive) { + restore(snapshot) + } + case .overwriteBase, .deleteSuspendState: + EmptyView() + } + } message: { confirmation in + switch confirmation { + case .delete(_): + Text("Do you want to delete the snapshot “\(snapshot.title)” and all its data?") + case .restore(_): + Text("Do you want to restore the snapshot “\(snapshot.title)”? The current state of the virtual machine will be overwritten and any changes that are not saved as a snapshot will be lost.") + case .overwriteBase, .deleteSuspendState: + EmptyView() + } + } + .id(snapshot.id) + } + } + .background(GeometryReader { geometry in + Color.clear.preference(key: WidthPreferenceKey.self, value: geometry.size.width) + }) + .onPreferenceChange(WidthPreferenceKey.self) { width in + self.width = width + } + .task(id: vm.state) { + // snapshots can only change while the VM does something + try? await list.refresh() + } + #if os(macOS) + .modifier(SnapshotKeyboardCommands(isFocused: $isTimelineFocused) { + if let snapshot = list.snapshot(for: selectedID), snapshot.canDelete { + confirmation = .delete(snapshot.id) + } + } onExit: { + select(nil) + }) + #endif + } + + private var emptyPlaceholder: some View { + VStack(spacing: 4) { + Text("No Snapshots") + .font(.headline) + Text("Save the current state of the virtual machine as a snapshot to return to it later.") + .font(.subheadline) + .multilineTextAlignment(.center) + }.foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } + + private var currentStatus: String { + if !vm.isStopped || vm.state == .paused { + return vm.stateLabel + } else if vm.hasSuspendState { + return NSLocalizedString("Suspended", comment: "VMSnapshotsView") + } else { + return NSLocalizedString("Not running", comment: "VMSnapshotsView") + } + } + + /// - Parameter hasState: Whether the snapshot holds the running machine, `nil` for the current state + private func rail(for link: Link, hasState: Bool?) -> SnapshotRail { + SnapshotRail(marker: hasState.map { SnapshotRail.Marker.snapshot(hasState: $0) } ?? .current, + isInLineage: link.isInLineage, + up: link.connectsUp ? (link.isUpInLineage ? .lineage : .normal) : nil, + down: link.connectsDown ? (link.isInLineage ? .lineage : .normal) : nil, + hasDistantParent: link.distantParent != nil) + } + + // MARK: Actions + + /// The current state can be kept as a snapshot unless it already is one. Running the VM is + /// left to the rest of the window. + @ViewBuilder private var currentActions: some View { + if vm.hasSuspendState { + DestructiveButton { + confirmation = .deleteSuspendState + } label: { + Label("Delete", systemImage: "trash") + }.help("Delete the suspended state so the virtual machine starts up normally") + .disabled(!isReady) + } + if !list.isCurrentStateSaved { + if let baseSnapshot = baseSnapshot { + Button { + confirmation = .overwriteBase + } label: { + Label("Overwrite", systemImage: "square.and.arrow.down") + }.help("Replace the snapshot “\(baseSnapshot.title)” with the current state") + .disabled(!list.canOverwriteBase) + } + Button { + createSnapshot() + } label: { + Label("New", systemImage: "plus") + }.help("Save the current state as a new snapshot") + .buttonStyle(.borderedProminent) + .disabled(!list.canCreate) + } + } + + @ViewBuilder private func actions(for snapshot: VMSnapshot) -> some View { + DestructiveButton { + confirmation = .delete(snapshot.id) + } label: { + Label("Delete", systemImage: "trash") + .labelStyle(.iconOnly) + }.help("Delete this snapshot") + .disabled(!snapshot.canDelete) + Button { + renamingID = snapshot.id + } label: { + Label("Rename", systemImage: "pencil") + .labelStyle(.iconOnly) + }.help("Rename this snapshot") + Button { + confirmation = .restore(snapshot.id) + } label: { + Label("Restore", systemImage: "arrow.uturn.backward") + }.help("Replace the current state with this snapshot") + .buttonStyle(.borderedProminent) + .disabled(!snapshot.canRestore) + } + + @ViewBuilder private func menuActions(for snapshot: VMSnapshot) -> some View { + Button { + confirmation = .restore(snapshot.id) + } label: { + Label("Restore", systemImage: "arrow.uturn.backward") + }.disabled(!snapshot.canRestore) + Button { + select(snapshot.id) + renamingID = snapshot.id + } label: { + Label("Rename", systemImage: "pencil") + } + Divider() + DestructiveButton { + confirmation = .delete(snapshot.id) + } label: { + Label("Delete", systemImage: "trash") + }.disabled(!snapshot.canDelete) + } + + /// - Parameter id: Snapshot the row is for, `nil` for the current state + private func isConfirming(_ id: UUID?) -> Binding { + Binding { + confirmation != nil && confirmation?.id == id + } set: { isPresented in + if !isPresented && confirmation?.id == id { + confirmation = nil + } + } + } + + /// Show that the VM is busy during `work` and show the error if it fails + private func perform(_ work: @escaping @MainActor @Sendable () async throws -> Void) { + data.busyWorkAsync { + try await work() + } + } + + private func select(_ id: UUID?) { + guard selectedID != id else { + return + } + renamingID = nil + isTimelineFocused = id != nil + withAnimation(.easeInOut(duration: 0.2)) { + selectedID = id + } + } + + private func selectParent(_ parent: VMSnapshot) { + select(parent.id) + scrollTo(parent.id) + } + + private func createSnapshot() { + perform { + let id = try await list.createSnapshot() + // let the default title be replaced right away + selectedID = id + renamingID = id + } + } + + private func overwriteBase() { + perform { + try await list.overwriteBase() + } + } + + private func deleteSuspendState() { + perform { + try await list.deleteSuspendState() + } + } + + private func delete(_ snapshot: VMSnapshot) { + perform { + try await list.delete(snapshot) + if selectedID == snapshot.id { + selectedID = nil + } + } + } + + /// Replace the current state with the snapshot. A VM that is off resumes from it once it is run. + private func restore(_ snapshot: VMSnapshot) { + perform { + try await list.restore(snapshot) + selectedID = nil + } + } +} + +#if os(macOS) +/// Delete and Escape for the selected snapshot. +/// +/// The commands only reach a view that has focus. One that is not a control shows a focus ring +/// around all of it which cannot be turned off before macOS 14, so older versions go without. +private struct SnapshotKeyboardCommands: ViewModifier { + let isFocused: FocusState.Binding + let onDelete: () -> Void + let onExit: () -> Void + + func body(content: Content) -> some View { + if #available(macOS 14, *) { + content + .focusable() + .focusEffectDisabled() + .focused(isFocused) + .onDeleteCommand(perform: onDelete) + .onExitCommand(perform: onExit) + } else { + content + } + } +} +#endif + +private struct WidthPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat? = nil + + static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { + value = nextValue() ?? value + } +} + +// MARK: - Row + +/// Shared layout of the current state and of a snapshot. +/// +/// Only the focused row shows its actions to keep the rest of the list quiet. They sit on the +/// trailing edge where there is room and move below the text on narrow screens. +private struct SnapshotRow: View { + let isCompact: Bool + let isFocused: Bool + let isSelected: Bool + let rail: SnapshotRail + let distantParent: VMSnapshot? + let onSelect: () -> Void + let onSelectParent: (VMSnapshot) -> Void + @ViewBuilder let thumbnail: () -> Thumbnail + @ViewBuilder let title: () -> Title + @ViewBuilder let details: () -> Details + @ViewBuilder let actions: () -> Actions + + @State private var isHovered: Bool = false + + private static var verticalPadding: CGFloat { 8 } + + private var thumbnailHeight: CGFloat { + SnapshotThumbnail.height(isCompact: isCompact) + } + + private var showsActions: Bool { + isFocused || isHovered + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + thumbnail() + VStack(alignment: .leading, spacing: 2) { + title() + details() + .font(.subheadline) + .foregroundColor(.secondary) + if let parent = distantParent { + parentLink(to: parent) + } + }.lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + if !isCompact { + // always laid out so that focusing a row does not reflow its text + actionBar + .frame(height: thumbnailHeight) + .opacity(showsActions ? 1 : 0) + .disabled(!showsActions) + .accessibilityHidden(!showsActions) + } + } + if isCompact && isFocused { + actionBar + .frame(maxWidth: .infinity, alignment: .trailing) + .transition(.opacity) + } + }.padding(.vertical, Self.verticalPadding) + .padding(.horizontal, 8) + .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(highlight)) + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + .onHover { isHovered = $0 } + .padding(.leading, SnapshotRail.width) + .background(alignment: .leading) { + rail.markerCenter(Self.verticalPadding + thumbnailHeight / 2) + .frame(width: SnapshotRail.width) + } + .accessibilityElement(children: .contain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private var actionBar: some View { + HStack(spacing: 8) { + actions() + }.labelStyle(.titleAndIcon) + .buttonStyle(.bordered) + .controlSize(.small) + .fixedSize() + } + + private var highlight: Color { + if isSelected { + return .accentColor.opacity(0.15) + } else if isHovered { + return .primary.opacity(0.05) + } else { + return .clear + } + } + + /// Names the parent when the rail cannot lead to it because other snapshots are in between. + private func parentLink(to parent: VMSnapshot) -> some View { + Button { + onSelectParent(parent) + } label: { + Label { + Text("From “\(parent.title)”") + } icon: { + Image(systemName: "arrow.triangle.branch") + }.labelStyle(.titleAndIcon) + .font(.caption) + }.buttonStyle(.plain) + .foregroundColor(.accentColor) + .padding(.top, 2) + .help("Show the snapshot this one was created from") + } +} + +// MARK: - Rail + +/// Vertical line with a marker for each row. Rows that follow their parent are joined into one +/// uninterrupted line so that a lineage without branches reads as a plain timeline. +private struct SnapshotRail: View { + static let width: CGFloat = 20 + static let lineWidth: CGFloat = 2 + + enum Marker { + case current + /// Solid when the snapshot holds the running machine, hollow when it holds only the disks + case snapshot(hasState: Bool) + } + + enum Style { + case normal + case lineage + + var color: Color { + switch self { + case .normal: return .secondary.opacity(0.35) + case .lineage: return .accentColor + } + } + } + + struct Segment: View { + let style: Style + + var body: some View { + Rectangle() + .fill(style.color) + .frame(width: SnapshotRail.lineWidth) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + static let markerSize: CGFloat = 14 + + let marker: Marker + let isInLineage: Bool + let up: Style? + let down: Style? + let hasDistantParent: Bool + /// Distance from the top of the row to the center of the marker + var markerCenter: CGFloat = 0 + + private var style: Style { + isInLineage ? .lineage : .normal + } + + private var markerColor: Color { + style == .lineage ? .accentColor : .secondary.opacity(0.6) + } + + var body: some View { + VStack(spacing: 0) { + segment(up) + .frame(height: max(0, markerCenter - Self.markerSize / 2)) + markerView + .frame(width: Self.markerSize, height: Self.markerSize) + if hasDistantParent { + // loose end hinting that the lineage carries on further down + VStack(spacing: 0) { + Line() + .stroke(style.color, style: StrokeStyle(lineWidth: Self.lineWidth, lineCap: .round, dash: [1, 4])) + .frame(width: Self.lineWidth, height: 12) + Spacer(minLength: 0) + }.frame(maxHeight: .infinity) + } else { + segment(down) + } + }.accessibilityHidden(true) + } + + func markerCenter(_ markerCenter: CGFloat) -> SnapshotRail { + var rail = self + rail.markerCenter = markerCenter + return rail + } + + @ViewBuilder private func segment(_ style: Style?) -> some View { + if let style = style { + Segment(style: style) + } else { + Color.clear + } + } + + @ViewBuilder private var markerView: some View { + switch marker { + case .current: + Circle() + .strokeBorder(Color.accentColor, lineWidth: Self.lineWidth) + .background(Circle().fill(Color.accentColor).padding(4)) + case .snapshot(let hasState): + Group { + if hasState { + Circle().fill(markerColor) + } else { + Circle().strokeBorder(markerColor, lineWidth: 1.5) + } + }.frame(width: 9, height: 9) + } + } + + private struct Line: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + return path + } + } +} + +// MARK: - Row content + +private struct SnapshotThumbnail: View { + let image: PlatformImage? + let isCompact: Bool + + static func height(isCompact: Bool) -> CGFloat { + (isCompact ? 80 : 96) * 9 / 16 + } + + private var width: CGFloat { + Self.height(isCompact: isCompact) * 16 / 9 + } + + var body: some View { + ZStack { + if let image = image { + Color.black + #if os(macOS) + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + #else + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + #endif + } else { + Color.secondary.opacity(0.15) + Image(systemName: "display") + .font(.title2) + .foregroundColor(.secondary.opacity(0.6)) + } + }.frame(width: width, height: Self.height(isCompact: isCompact)) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 6, style: .continuous).strokeBorder(Color.primary.opacity(0.1))) + .accessibilityHidden(true) + } +} + +private struct SnapshotTitle: View { + let title: String + let isEditing: Bool + /// Called with the new title or `nil` if editing was cancelled + let onCommit: (String?) -> Void + + @State private var draft: String = "" + @FocusState private var isFocused: Bool + + var body: some View { + if isEditing { + TextField("Name", text: $draft) + .font(.headline) + .textFieldStyle(.roundedBorder) + .focused($isFocused) + .onSubmit { + onCommit(draft) + } + #if os(macOS) + .onExitCommand { + onCommit(nil) + } + #endif + .onChange(of: isFocused) { isFocused in + if !isFocused { + onCommit(draft) + } + } + .onAppear { + draft = title + // focus is not accepted until the field has settled in the hierarchy, + // which takes a moment on older systems + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + isFocused = true + } + } + } else { + Text(title) + .font(.headline) + } + } +} + +private struct SnapshotDetails: View { + let snapshot: VMSnapshot + /// The size is shown next to the title when there is no room for it here + let isCompact: Bool + + static func size(of snapshot: VMSnapshot) -> Text { + Text(ByteCountFormatter.string(fromByteCount: snapshot.size, countStyle: .binary)) + } + + /// Not every backend knows how much the disks of a snapshot occupy + private var size: Text? { + snapshot.size > 0 ? Self.size(of: snapshot) : nil + } + + /// Note for a snapshot that the VM starts up from instead of resuming + private var kind: Text? { + snapshot.hasState ? nil : Text("Disks Only") + } + + private func joined(_ parts: Text?...) -> Text { + let parts = parts.compactMap { $0 } + guard let first = parts.first else { + return Text(verbatim: "") + } + return parts.dropFirst().reduce(first) { $0 + separator + $1 } + } + + private func created(_ style: Date.FormatStyle.DateStyle) -> Text { + Text("Created \(format(snapshot.dateCreated, style))") + } + + private func modified(_ style: Date.FormatStyle.DateStyle) -> Text { + Text("Modified \(format(snapshot.dateModified, style))") + } + + /// The year is only worth its space when it is not the current one. + private func format(_ date: Date, _ style: Date.FormatStyle.DateStyle) -> String { + if style == .abbreviated && Calendar.current.isDate(date, equalTo: Date(), toGranularity: .year) { + return date.formatted(.dateTime.month(.abbreviated).day().hour().minute()) + } else { + return date.formatted(date: style, time: .shortened) + } + } + + private var separator: Text { + Text(verbatim: " · ") + } + + var body: some View { + if isCompact { + VStack(alignment: .leading, spacing: 2) { + created(.abbreviated) + modified(.abbreviated) + if let kind = kind { + kind + } + } + } else if #available(iOS 16, macOS 13, *) { + ViewThatFits(in: .horizontal) { + joined(created(.abbreviated), modified(.abbreviated), size, kind) + stacked + } + } else { + stacked + } + } + + /// Both lines end up about as long as each other + private var stacked: some View { + VStack(alignment: .leading, spacing: 2) { + joined(created(.abbreviated), kind) + joined(modified(.abbreviated), size) + } + } +} diff --git a/Platform/UTMData.swift b/Platform/UTMData.swift index 861630a9..d66747fb 100644 --- a/Platform/UTMData.swift +++ b/Platform/UTMData.swift @@ -911,6 +911,10 @@ enum AlertItem: Identifiable { /// - Parameter driveUrl: Original drive to convert /// - Parameter isCompressed: Compress existing data func reclaimSpace(for driveUrl: URL, withCompression isCompressed: Bool = false) async throws { + // converting leaves the snapshots in the image behind, and a suspended state is one of them + guard try await UTMQemuImage.snapshots(image: driveUrl).isEmpty else { + throw UTMDataError.driveHasSnapshots + } let baseUrl = driveUrl.deletingLastPathComponent() let dstUrl = Self.newImage(from: driveUrl, to: baseUrl, withExtension: "qcow2") defer { @@ -1195,6 +1199,7 @@ enum UTMDataError: Error { case jitStreamerUrlInvalid(String) case notImplemented case reconnectFailed + case driveHasSnapshots } extension UTMDataError: LocalizedError { @@ -1228,6 +1233,8 @@ extension UTMDataError: LocalizedError { return NSLocalizedString("This functionality is not yet implemented.", comment: "UTMData") case .reconnectFailed: return NSLocalizedString("Failed to reconnect to the server.", comment: "UTMData") + case .driveHasSnapshots: + return NSLocalizedString("Space cannot be reclaimed from a drive while it holds snapshots or a suspended state.", comment: "UTMData") } } } diff --git a/Platform/VMSnapshotData.swift b/Platform/VMSnapshotData.swift new file mode 100644 index 00000000..f15e8f59 --- /dev/null +++ b/Platform/VMSnapshotData.swift @@ -0,0 +1,238 @@ +// +// 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 SwiftUI + +/// A saved state of a virtual machine as shown in the snapshots list. +struct VMSnapshot: Identifiable, Equatable { + let id: UUID + + /// User-editable display name + var title: String + + /// Snapshot the current state was based on when this one was created, `nil` if there was none + var parentID: UUID? + + var dateCreated: Date + + /// Last time the snapshot was renamed or overwritten + var dateModified: Date + + /// Bytes occupied on disk + var size: Int64 + + /// Screen contents when the snapshot was taken, if one was captured + var screenshot: PlatformImage? + + /// Includes the running state of the VM, otherwise it starts up from the saved disks + var hasState: Bool + + /// The saved state is missing so the snapshot can only be deleted + var isOrphaned: Bool + + /// The saved state does not cover every drive so the snapshot cannot be restored + var isIncomplete: Bool = false + + /// Possible while the VM is in its current state, which differs between running and not + var canRestore: Bool = true + + /// Possible while the VM is in its current state, which differs between running and not + var canDelete: Bool = true + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + && lhs.title == rhs.title + && lhs.parentID == rhs.parentID + && lhs.dateCreated == rhs.dateCreated + && lhs.dateModified == rhs.dateModified + && lhs.size == rhs.size + && lhs.screenshot === rhs.screenshot + && lhs.hasState == rhs.hasState + && lhs.isOrphaned == rhs.isOrphaned + && lhs.isIncomplete == rhs.isIncomplete + && lhs.canRestore == rhs.canRestore + && lhs.canDelete == rhs.canDelete + } +} + +/// Snapshots of a single VM along with the "current" state they are listed under. +@MainActor +final class VMSnapshotList: ObservableObject { + /// Saved snapshots, newest first + @Published private(set) var snapshots: [VMSnapshot] = [] + + /// Snapshot the current state is based on, `nil` if there is none + @Published private(set) var currentParentID: UUID? + + /// Nothing changed since the snapshot the current state is based on + @Published private(set) var isCurrentStateSaved: Bool = false + + /// The current state can be saved while the VM is in its current state + @Published private(set) var canCreate: Bool = false + + /// The snapshot the current state is based on can be replaced while the VM is in its current state + @Published private(set) var canOverwriteBase: Bool = false + + /// Weak so that the cache does not keep a VM alive after it leaves the library + private weak var vm: VMData? + + /// Screenshots by file + private var screenshots: [URL: PlatformImage] = [:] + + private static var cache: [UUID: VMSnapshotList] = [:] + + private var manifestObserver: (any NSObjectProtocol)? + + /// Returns the list for a VM, keeping it alive so it is not read again every time it is shown. + static func list(for vm: VMData) -> VMSnapshotList { + cache = cache.filter { $0.value.vm != nil } + if let list = cache[vm.id] { + return list + } + let list = VMSnapshotList(vm: vm) + cache[vm.id] = list + return list + } + + private init(vm: VMData) { + self.vm = vm + // snapshots also change through scripting while they are shown + manifestObserver = NotificationCenter.default.addObserver(forName: UTMSnapshotService.didChangeNotification, object: nil, queue: .main) { [weak self] notification in + let bundleURL = notification.object as? URL + Task { @MainActor [weak self] in + guard let self = self, let bundleURL = bundleURL, bundleURL.standardizedFileURL == self.vm?.wrapped?.pathUrl.standardizedFileURL else { + return + } + try? await self.refresh() + } + } + } + + private var wrapped: any UTMVirtualMachine { + get throws { + guard let wrapped = vm?.wrapped else { + throw UTMSnapshotError.notSupported + } + return wrapped + } + } + + // MARK: - Lineage + + func snapshot(for id: UUID?) -> VMSnapshot? { + guard let id = id else { + return nil + } + return snapshots.first { $0.id == id } + } + + /// Identifiers of every ancestor starting at `id` (inclusive) and walking up the parents. + func lineage(from id: UUID?) -> Set { + var lineage = Set() + var next = snapshot(for: id) + while let snapshot = next, !lineage.contains(snapshot.id) { + lineage.insert(snapshot.id) + next = self.snapshot(for: snapshot.parentID) + } + return lineage + } + + // MARK: - Operations + + /// Read the snapshots of the VM. The first time this adds those made by other tools. + func refresh() async throws { + let wrapped = try wrapped + let timeline = try await UTMSnapshotService.timeline(for: wrapped) + snapshots = timeline.snapshots.map { snapshot in + VMSnapshot(id: snapshot.id, + title: snapshot.title, + parentID: snapshot.parentID, + dateCreated: snapshot.dateCreated, + dateModified: snapshot.dateModified, + size: snapshot.size, + screenshot: screenshot(at: snapshot.screenshotURL), + hasState: snapshot.hasState, + isOrphaned: snapshot.isOrphaned, + isIncomplete: snapshot.isIncomplete, + canRestore: !snapshot.isOrphaned && !snapshot.isIncomplete && UTMSnapshotService.isSupported(.restore, for: snapshot, on: wrapped), + canDelete: UTMSnapshotService.isSupported(.delete, for: snapshot, on: wrapped)) + } + // pictures of snapshots that are gone are not needed anymore + let screenshotURLs = Set(timeline.snapshots.compactMap { $0.screenshotURL }) + screenshots = screenshots.filter { screenshotURLs.contains($0.key) } + currentParentID = timeline.currentParentID + isCurrentStateSaved = timeline.isCurrentStateSaved + canCreate = UTMSnapshotService.isSupported(.create, on: wrapped) + // overwriting saves the current state and deletes what the snapshot held + canOverwriteBase = canCreate && snapshot(for: currentParentID)?.canDelete == true + } + + private func screenshot(at url: URL?) -> PlatformImage? { + guard let url = url else { + return nil + } + if screenshots[url] == nil { + screenshots[url] = PlatformImage(contentsOfURL: url) + } + return screenshots[url] + } + + func rename(_ snapshot: VMSnapshot, to title: String) async throws { + try UTMSnapshotService.renameSnapshot(snapshot.id, to: title, on: try wrapped) + try await refresh() + } + + /// Saves the current state as a new snapshot based on the one the current state was based on. + /// - Returns: Identifier of the new snapshot + @discardableResult + func createSnapshot() async throws -> UUID { + let snapshot = try await UTMSnapshotService.createSnapshot(on: try wrapped) + try await refresh() + return snapshot.id + } + + /// Replaces what the snapshot the current state is based on holds with the current state. + func overwriteBase() async throws { + guard let currentParentID = currentParentID else { + return + } + try await UTMSnapshotService.overwriteSnapshot(currentParentID, on: try wrapped) + try await refresh() + } + + func delete(_ snapshot: VMSnapshot) async throws { + try await UTMSnapshotService.deleteSnapshot(snapshot.id, on: try wrapped) + try await refresh() + } + + /// Replaces the current state with `snapshot`. If the VM is not running, it resumes from it when started. + func restore(_ snapshot: VMSnapshot) async throws { + do { + try await UTMSnapshotService.restoreSnapshot(snapshot.id, on: try wrapped) + } catch { + // show snapshots found to be orphaned + try? await refresh() + throw error + } + try await refresh() + } + + /// Discards the state the VM is suspended to so that it starts up normally. + func deleteSuspendState() async throws { + try await wrapped.deleteSnapshot(name: nil) + try await refresh() + } +} diff --git a/QEMULauncher/Bootstrap.c b/QEMULauncher/Bootstrap.c index 1b878bef..dc9eaef6 100644 --- a/QEMULauncher/Bootstrap.c +++ b/QEMULauncher/Bootstrap.c @@ -78,7 +78,8 @@ static void __attribute__((noreturn)) runQemu(qemu_main_t *funcs, int argc, cons pthread_create(&thread, NULL, WatchForParentTermination, NULL); pthread_detach(thread); if (funcs->main) { - funcs->main(argc, argv); + // tools such as qemu-img report most failures by returning + exit(funcs->main(argc, argv)); } else if (funcs->swtpm_main) { funcs->swtpm_main(argc, argv, "swtpm", "socket"); } else { diff --git a/Scripting/UTM.sdef b/Scripting/UTM.sdef index aa317f99..c8f1a508 100644 --- a/Scripting/UTM.sdef +++ b/Scripting/UTM.sdef @@ -127,32 +127,32 @@ - + - + + - - - - - + + - - - - - + + - - - - - + + + + + + + + + + @@ -208,6 +208,11 @@ description="Serial ports exposed by the guest to the host to access."> + + + + @@ -233,16 +238,12 @@ - - + + - - - - - - + + @@ -251,6 +252,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SBElementArray + @objc optional func snapshots() -> SBElementArray @objc optional func id() -> String // The unique identifier of the VM. @objc optional var name: String { get } // The name of the VM. @objc optional var backend: UTMScriptingBackend { get } // Emulation/virtualization engine used. @@ -240,10 +242,9 @@ extension SBObject: UTMScriptingWindow {} @objc optional func queryInstall() -> [AnyHashable : Any] // Query the progress of the last installation started with install. Raises an error if the installation failed. @objc optional func suspendSaving(_ saving: Bool) // Suspend a running virtual machine to memory. @objc optional func stopBy(_ by: UTMScriptingStopMethod) // Shuts down a running virtual machine. - @objc optional func createSnapshotNamed(_ named: String!) // Create or replace a named full-VM snapshot (RAM, devices, and disk) for a QEMU virtual machine. The VM must be running or paused. - @objc optional func listSnapshots() -> [Any] // List the names of all named snapshots for a stopped QEMU virtual machine, newest first. - @objc optional func restoreSnapshotNamed(_ named: String!) // Restore a stopped QEMU virtual machine to a named snapshot. - @objc optional func deleteSnapshotNamed(_ named: String!) // Delete a named snapshot from a QEMU virtual machine. + @objc optional func createSnapshotNamed(_ named: String!) -> UTMScriptingSnapshot // Save the current state of a virtual machine as a new snapshot. A running QEMU virtual machine is saved along with its running state, otherwise only the disks are. + @objc optional func scanSnapshots() // Look through the disk images of a virtual machine for snapshots that UTM does not know about yet, and mark the ones whose data has gone missing. Only needed for snapshots made by other tools. + @objc optional func discardSavedState() // Throw away the state a suspended virtual machine would resume from, so that it starts up normally instead. Snapshots are not affected. @objc optional func delete() // Delete a virtual machine. All data will be deleted, there is no confirmation! @objc optional func duplicateWithProperties(_ withProperties: [AnyHashable : Any]!) // Copy an virtual machine and all its data. @objc optional func exportTo(_ to: URL!) // Export a virtual machine to a specified location. @@ -263,6 +264,23 @@ extension SBObject: UTMScriptingWindow {} } extension SBObject: UTMScriptingVirtualMachine {} +// MARK: UTMScriptingSnapshot +@objc public protocol UTMScriptingSnapshot: SBObjectProtocol, UTMScriptingGenericMethods { + @objc optional func id() -> String // The unique identifier of the snapshot. + @objc optional var name: String { get } // Name shown for the snapshot. Setting it renames the snapshot; a name already in use gets a number appended. + @objc optional var creationDate: Date { get } // When the snapshot was first saved. + @objc optional var modificationDate: Date { get } // When the snapshot was last renamed or overwritten. + @objc optional var size: Double { get } // Bytes the snapshot occupies. + @objc optional var includesRunningState: Bool { get } // Whether the snapshot holds the running machine as well as the disks. Restoring one that does not means the virtual machine starts up instead of resuming. + @objc optional var dataMissing: Bool { get } // Whether the data behind the snapshot has gone missing, in which case it can only be deleted. + @objc optional var basedOn: String { get } // Identifier of the snapshot the current state was based on when this one was saved, or empty if it was not based on any. + @objc optional func restore() // Replace the current state of a virtual machine with a snapshot. A virtual machine that is not running resumes from it the next time it is started. + @objc optional func deleteSnapshot() // Delete a snapshot. Its data is deleted with it, there is no confirmation! + @objc optional func overwrite() // Replace what a snapshot holds with the current state of the virtual machine. The snapshot keeps its name and its place among the others. + @objc optional func setName(_ name: String!) // Name shown for the snapshot. Setting it renames the snapshot; a name already in use gets a number appended. +} +extension SBObject: UTMScriptingSnapshot {} + // MARK: UTMScriptingSerialPort @objc public protocol UTMScriptingSerialPort: SBObjectProtocol, UTMScriptingGenericMethods { @objc optional func id() -> Int // The unique identifier of the tag. diff --git a/Scripting/UTMScriptingDeleteCommand.swift b/Scripting/UTMScriptingDeleteCommand.swift index 356c450e..ce86d3a1 100644 --- a/Scripting/UTMScriptingDeleteCommand.swift +++ b/Scripting/UTMScriptingDeleteCommand.swift @@ -20,6 +20,12 @@ import Foundation @objc(UTMScriptingDeleteCommand) class UTMScriptingDeleteCommand: NSDeleteCommand, UTMScriptable { override func performDefaultImplementation() -> Any? { + // a specifier for something inside a virtual machine, such as one of its snapshots, can + // be coerced into the virtual machine itself, and deleting that would throw away far + // more than was asked for + guard keySpecifier.key == "scriptingVirtualMachines" else { + return super.performDefaultImplementation() + } if let scriptingVM = keySpecifier.objectsByEvaluatingSpecifier as? UTMScriptingVirtualMachineImpl { scriptingVM.delete(self) return nil diff --git a/Scripting/UTMScriptingSnapshotImpl.swift b/Scripting/UTMScriptingSnapshotImpl.swift new file mode 100644 index 00000000..bf76c77b --- /dev/null +++ b/Scripting/UTMScriptingSnapshotImpl.swift @@ -0,0 +1,114 @@ +// +// 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 + +@MainActor +@objc(UTMScriptingSnapshotImpl) +class UTMScriptingSnapshotImpl: NSObject, UTMScriptable { + /// Read when the object is resolved, which happens anew for every event + private var snapshot: UTMSnapshot + /// Held strongly because a command runs after the accessor that made this object has returned + @objc private(set) var parent: UTMScriptingVirtualMachineImpl + + private var vm: (any UTMVirtualMachine)! { + parent.vm + } + + init(snapshot: UTMSnapshot, parent: UTMScriptingVirtualMachineImpl) { + self.snapshot = snapshot + self.parent = parent + } + + @objc var id: String { + snapshot.id.uuidString + } + + @objc var name: String { + get { + snapshot.title + } + + set { + do { + snapshot = try UTMSnapshotService.renameSnapshot(snapshot.id, to: newValue, on: vm) + } catch { + let command = NSScriptCommand.current() + command?.scriptErrorNumber = errOSAGeneralError + command?.scriptErrorString = error.localizedDescription + } + } + } + + @objc var creationDate: Date { + snapshot.dateCreated + } + + @objc var modificationDate: Date { + snapshot.dateModified + } + + @objc var size: Int { + Int(snapshot.size) + } + + @objc var includesRunningState: Bool { + snapshot.hasState + } + + @objc var isDataMissing: Bool { + snapshot.isOrphaned + } + + /// Identifier of the snapshot this one was based on, empty if it was not based on any + @objc var basedOn: String { + snapshot.parentID?.uuidString ?? "" + } + + override var objectSpecifier: NSScriptObjectSpecifier? { + guard let parentDescription = parent.classDescription as? NSScriptClassDescription else { + return nil + } + return NSUniqueIDSpecifier(containerClassDescription: parentDescription, + containerSpecifier: parent.objectSpecifier, + key: "snapshots", + uniqueID: id) + } + + // MARK: - Commands + + @objc func restore(_ command: NSScriptCommand) { + withScriptCommand(command) { [self] in + try await UTMSnapshotService.restoreSnapshot(snapshot.id, on: vm) + } + } + + @objc func overwrite(_ command: NSScriptCommand) { + withScriptCommand(command) { [self] in + try await UTMSnapshotService.overwriteSnapshot(snapshot.id, on: vm) + } + } + + @objc func deleteSnapshot(_ command: NSScriptCommand) { + withScriptCommand(command) { [self] in + try await UTMSnapshotService.deleteSnapshot(snapshot.id, on: vm) + } + } + + static func == (lhs: UTMScriptingSnapshotImpl, rhs: UTMScriptingSnapshotImpl) -> Bool { + lhs.snapshot.id == rhs.snapshot.id && lhs.parent == rhs.parent + } +} diff --git a/Scripting/UTMScriptingVirtualMachineImpl.swift b/Scripting/UTMScriptingVirtualMachineImpl.swift index a0e12634..28fcbf91 100644 --- a/Scripting/UTMScriptingVirtualMachineImpl.swift +++ b/Scripting/UTMScriptingVirtualMachineImpl.swift @@ -91,6 +91,28 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable { } } + @objc var snapshots: [UTMScriptingSnapshotImpl] { + UTMSnapshotService.snapshots(for: vm).map { UTMScriptingSnapshotImpl(snapshot: $0, parent: self) } + } + + /// Resolve `snapshot id "..." of virtual machine ...` + /// + /// Without this a specifier for a snapshot cannot be evaluated and falls back to this virtual + /// machine, which would aim a command meant for one snapshot at the whole VM. + @objc(valueInSnapshotsWithUniqueID:) + func valueInSnapshots(withUniqueID id: Any) -> UTMScriptingSnapshotImpl? { + guard let id = id as? String else { + return nil + } + return snapshots.first { $0.id == id } + } + + /// Resolve `snapshot "..." of virtual machine ...` + @objc(valueInSnapshotsWithName:) + func valueInSnapshots(withName name: String) -> UTMScriptingSnapshotImpl? { + snapshots.first { $0.name == name } + } + var guestAgent: QEMUGuestAgent! { get async { await (vm as? UTMQemuVirtualMachine)?.guestAgent @@ -290,37 +312,23 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable { @objc func createSnapshot(_ command: NSScriptCommand) { let name = command.evaluatedArguments?["snapshotName"] as? String withScriptCommand(command) { [self] in - guard let name = name else { - throw ScriptingError.invalidParameter - } - try await UTMSnapshotService.createSnapshot(name: name, on: vm) + let snapshot = try await UTMSnapshotService.createSnapshot(title: name, on: vm) + return UTMScriptingSnapshotImpl(snapshot: snapshot, parent: self) } } - @objc func listSnapshots(_ command: NSScriptCommand) { + @objc func scanSnapshots(_ command: NSScriptCommand) { withScriptCommand(command) { [self] in - let entries = try await UTMSnapshotService.listSnapshots(on: vm) - return entries.map { $0.name } + try await UTMSnapshotService.scanSnapshots(on: vm) } } - @objc func restoreSnapshot(_ command: NSScriptCommand) { - let name = command.evaluatedArguments?["snapshotName"] as? String + @objc func discardSavedState(_ command: NSScriptCommand) { withScriptCommand(command) { [self] in - guard let name = name else { - throw ScriptingError.invalidParameter + guard vm.registryEntry.isSuspended else { + return } - try await UTMSnapshotService.restoreSnapshot(name: name, on: vm) - } - } - - @objc func deleteSnapshot(_ command: NSScriptCommand) { - let name = command.evaluatedArguments?["snapshotName"] as? String - withScriptCommand(command) { [self] in - guard let name = name else { - throw ScriptingError.invalidParameter - } - try await UTMSnapshotService.deleteSnapshot(name: name, on: vm) + try await vm.deleteSnapshot(name: nil) } } diff --git a/Services/UTMAppleDiskImage.swift b/Services/UTMAppleDiskImage.swift index 1607628e..7e17a436 100644 --- a/Services/UTMAppleDiskImage.swift +++ b/Services/UTMAppleDiskImage.swift @@ -48,6 +48,7 @@ enum UTMAppleDiskImage { } private static let bytesInMib = 1048576 + fileprivate static let layerFileExtension = "asif" /// Block size used when creating images, matches the legacy DiskImages2 path private static let blockSize = 512 @@ -95,12 +96,25 @@ enum UTMAppleDiskImage { } } + /// Files beside an image that its snapshots stacked on it, found by name so that this works + /// where the layers themselves cannot be read + static func layerFileURLs(of imageURL: URL) -> [URL] { + let prefix = imageURL.lastPathComponent + "." + // an image outside of the bundle may be in a directory that cannot be read + let siblings = (try? FileManager.default.contentsOfDirectory(at: imageURL.deletingLastPathComponent(), includingPropertiesForKeys: nil)) ?? [] + return siblings.filter { $0.lastPathComponent.hasPrefix(prefix) && $0.pathExtension == layerFileExtension } + } + /// Change the size of the virtual disk of an existing image /// - Parameters: /// - url: Location of the image /// - sizeMib: New size of the virtual disk in MiB @available(macOS 14, *) static func resize(_ url: URL, toSizeMib sizeMib: Int) throws { + // every layer stacked on the image was built on it as it is + guard layerFileURLs(of: url).isEmpty else { + throw UTMAppleDiskImageError.hasSnapshots + } if #available(macOS 27, *) { let image = try DiskImageKit.DiskImage(opening: .open(url: url, mode: .readWrite)) try image.truncate(blockCount: sizeMib * bytesInMib / image.blockSize.rawValue) @@ -114,19 +128,278 @@ enum UTMAppleDiskImage { @available(macOS 27, *) extension UTMAppleDiskImage { - /// Create an attachment whose guest writes land in a new copy-on-write layer + /// Copy-on-write layer stacked on the image of a drive. /// - /// The image at `url` is opened read-only and is never modified. Any existing file at `overlayURL` is replaced. + /// Layers are kept next to the image as `..asif`. Each one only holds what was + /// written while it was on top, so it depends on the layers below it. + struct Layer: Equatable { + /// Name of the layer that guest writes go to + static let currentName = "current" + + fileprivate static let fileExtension = layerFileExtension + fileprivate static let unlinkedMarker = "unlinked" + + let url: URL + + /// `currentName` or the snapshot the layer was frozen as + let name: String + + /// Not a snapshot anymore but still below other layers + let isUnlinked: Bool + + let uuid: UUID + + /// Layer right below this one, `nil` if that is the image itself + let parentUUID: UUID? + + var isCurrent: Bool { + name == Self.currentName + } + + fileprivate static func url(for imageURL: URL, name: String, isUnlinked: Bool = false) -> URL { + var url = imageURL.appendingPathExtension(name) + if isUnlinked { + url.appendPathExtension(unlinkedMarker) + } + return url.appendingPathExtension(fileExtension) + } + } + + /// Find the layers of an image. + /// + /// The current layer is left out while it cannot be opened because a VM is using it. + static func layers(of imageURL: URL) throws -> [Layer] { + let prefix = imageURL.lastPathComponent + "." + let candidates = layerFileURLs(of: imageURL) + guard !candidates.isEmpty else { + // most images have no layers, leave them alone + return [] + } + let baseUUID = try DiskImageKit.DiskImage(opening: .open(url: imageURL, mode: .readOnly)).layerUUID + return candidates.compactMap { url -> Layer? in + var name = String(url.deletingPathExtension().lastPathComponent.dropFirst(prefix.count)) + let isUnlinked = name.hasSuffix("." + Layer.unlinkedMarker) + if isUnlinked { + name.removeLast(Layer.unlinkedMarker.count + 1) + } + guard !name.isEmpty, let image = try? DiskImageKit.DiskImage(opening: .open(url: url, mode: .readOnly)), let uuid = image.layerUUID else { + return nil + } + // an image without an identifier cannot be told apart from no layer + let parentUUID = image.parentUUID == baseUUID ? nil : image.parentUUID + return Layer(url: url, name: name, isUnlinked: isUnlinked, uuid: uuid, parentUUID: parentUUID) + } + } + + /// Layers from the one right above the image up to `top` + /// + /// - Throws: `UTMAppleDiskImageError.layerMissing` when a layer below `top` cannot be read, + /// because opening the rest without it would hide everything that layer holds. + private static func chain(endingAt top: Layer?, in layers: [Layer]) throws -> [Layer] { + var chain = [Layer]() + var next = top + while let layer = next, !chain.contains(layer) { + chain.insert(layer, at: 0) + next = layers.first { $0.uuid == layer.parentUUID } + guard next != nil || layer.parentUUID == nil else { + throw UTMAppleDiskImageError.layerMissing + } + } + return chain + } + + /// Open an image along with some of its layers, all of them read-only unless `isTopWritable`. + private static func open(_ imageURL: URL, chain: [Layer], isTopWritable: Bool = false) throws -> DiskImageKit.DiskImage { + var image = try DiskImageKit.DiskImage(opening: .open(url: imageURL, mode: chain.isEmpty && isTopWritable ? .readWrite : .readOnly)) + for layer in chain { + let isWritable = isTopWritable && layer == chain.last + image = try image.appending(DiskImageKit.DiskImage(opening: .open(url: layer.url, mode: isWritable ? .readWrite : .readOnly))) + } + return image + } + + /// Put a new layer on top of `chain` that guest writes go to from now on. + private static func createCurrentLayer(of imageURL: URL, above chain: [Layer]) throws { + let fileManager = FileManager.default + let url = Layer.url(for: imageURL, name: Layer.currentName) + // built under a name that is not taken for a layer, so the one it replaces stays until it exists + let pendingURL = imageURL.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).\(Layer.fileExtension)") + do { + _ = try open(imageURL, chain: chain).appending(ASIFCreationConfiguration.layer(url: pendingURL, type: .overlay)) + if fileManager.fileExists(atPath: url.path) { + _ = try fileManager.replaceItemAt(url, withItemAt: pendingURL) + } else { + try fileManager.moveItem(at: pendingURL, to: url) + } + } catch { + try? fileManager.removeItem(at: pendingURL) + throw error + } + } + + /// Create an attachment for an image that has layers or that should not be modified. + /// + /// Any existing file at `overlayURL` is replaced. /// - Parameters: - /// - url: Location of the base image - /// - overlayURL: Location of the overlay layer to create + /// - url: Location of the image + /// - includesLayers: Look for layers, which snapshots only stack on the images in the bundle + /// - overlayURL: If set, guest writes land in a new layer created here and nothing else is modified + /// - isReadOnly: The drive may not be written to, which a stack cannot be attached as /// - cachingMode: Caching mode of the attachment /// - synchronizationMode: Synchronization mode of the attachment - static func attachment(for url: URL, ephemeralOverlayAt overlayURL: URL, cachingMode: VZDiskImageCachingMode = .automatic, synchronizationMode: VZDiskImageSynchronizationMode = .full) throws -> VZDiskImageStorageDeviceAttachment { - try? FileManager.default.removeItem(at: overlayURL) - let base = try DiskImageKit.DiskImage(opening: .open(url: url, mode: .readOnly)) - let stack = try base.appending(ASIFCreationConfiguration.layer(url: overlayURL, type: .overlay)) - return try VZDiskImageStorageDeviceAttachment(diskImage: stack, cachingMode: cachingMode, synchronizationMode: synchronizationMode) + /// - Returns: `nil` if the image has no layers and can be attached by itself + static func attachment(for url: URL, includesLayers: Bool, ephemeralOverlayAt overlayURL: URL? = nil, isReadOnly: Bool = false, cachingMode: VZDiskImageCachingMode = .automatic, synchronizationMode: VZDiskImageSynchronizationMode = .full) throws -> VZDiskImageStorageDeviceAttachment? { + let layers = includesLayers ? try layers(of: url) : [] + let current = layers.first { $0.isCurrent } + // writing to the image itself is only safe while nothing is stacked on it, otherwise it + // would change what every layer above it was built on + guard current != nil || !includesLayers || layerFileURLs(of: url).isEmpty else { + throw UTMAppleDiskImageError.layerMissing + } + let chain = try chain(endingAt: current, in: layers) + if let overlayURL = overlayURL { + try? FileManager.default.removeItem(at: overlayURL) + let stack = try open(url, chain: chain).appending(ASIFCreationConfiguration.layer(url: overlayURL, type: .overlay)) + return try VZDiskImageStorageDeviceAttachment(diskImage: stack, cachingMode: cachingMode, synchronizationMode: synchronizationMode) + } else if chain.isEmpty { + return nil + } else if isReadOnly { + // the image alone is the drive as it was before the first snapshot + throw UTMAppleDiskImageError.readOnlyWithSnapshots + } else { + return try VZDiskImageStorageDeviceAttachment(diskImage: try open(url, chain: chain, isTopWritable: true), cachingMode: cachingMode, synchronizationMode: synchronizationMode) + } + } + + /// Freeze what has been written to an image so far and direct further writes to a new layer. + /// + /// The image must not be in use. + /// - Parameters: + /// - imageURL: Location of the image + /// - name: Name of the snapshot + static func createSnapshot(of imageURL: URL, name: String) throws { + let fileManager = FileManager.default + let snapshotURL = Layer.url(for: imageURL, name: name) + let current = try layers(of: imageURL).first(where: { $0.isCurrent }) + // once there are layers only the one being written to may be frozen, or they would be lost + guard current != nil || layerFileURLs(of: imageURL).isEmpty else { + throw UTMAppleDiskImageError.layerMissing + } + if let current = current { + try fileManager.moveItem(at: current.url, to: snapshotURL) + // the date of a snapshot is when its layer was frozen, not when the guest last wrote to it + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path) + } else { + // so far everything was written to the image itself + _ = try open(imageURL, chain: []).appending(ASIFCreationConfiguration.layer(url: snapshotURL, type: .overlay)) + } + do { + let layers = try self.layers(of: imageURL) + try createCurrentLayer(of: imageURL, above: try chain(endingAt: layers.first { $0.name == name }, in: layers)) + } catch { + // without a layer to write to the drive could not be used anymore + if let current = current { + try? fileManager.moveItem(at: snapshotURL, to: current.url) + } else { + try? fileManager.removeItem(at: snapshotURL) + } + throw error + } + } + + /// Discard what was written to an image since a snapshot was created. + /// + /// The image must not be in use. + static func restoreSnapshot(of imageURL: URL, name: String) throws { + try createCurrentLayer(of: imageURL, above: try snapshotChain(of: imageURL, name: name)) + try removeUnusedLayers(of: imageURL) + } + + /// Make sure that a snapshot can be restored, without changing anything. + static func validateSnapshot(of imageURL: URL, name: String) throws { + _ = try snapshotChain(of: imageURL, name: name) + } + + private static func snapshotChain(of imageURL: URL, name: String) throws -> [Layer] { + let layers = try layers(of: imageURL) + guard let snapshot = layers.first(where: { $0.name == name && !$0.isUnlinked }) else { + throw UTMAppleDiskImageError.snapshotNotFound(name) + } + return try chain(endingAt: snapshot, in: layers) + } + + /// Delete a snapshot. Its layer stays for as long as there are layers above it. + static func deleteSnapshot(of imageURL: URL, name: String) throws { + guard let snapshot = try layers(of: imageURL).first(where: { $0.name == name && !$0.isUnlinked }) else { + throw UTMAppleDiskImageError.snapshotNotFound(name) + } + try FileManager.default.moveItem(at: snapshot.url, to: Layer.url(for: imageURL, name: name, isUnlinked: true)) + 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) + // the current layer is not found while it is in use so keep what may be below it + guard layers.contains(where: { $0.isCurrent }) || layers.isEmpty else { + return + } + while let unused = layers.first(where: { layer in layer.isUnlinked && !layers.contains { $0.parentUUID == layer.uuid } }) { + try FileManager.default.removeItem(at: unused.url) + layers.removeAll { $0 == unused } + } + } +} + +enum UTMAppleDiskImageError: Error { + case snapshotNotFound(String) + case layerMissing + case hasSnapshots + case snapshotsRequireNewerOS + case readOnlyWithSnapshots +} + +extension UTMAppleDiskImageError: LocalizedError { + var errorDescription: String? { + switch self { + case .snapshotNotFound(let name): + return String.localizedStringWithFormat(NSLocalizedString("The disk image has no snapshot '%@'.", comment: "UTMAppleDiskImage"), name) + case .layerMissing: + return NSLocalizedString("The disk image is incomplete because some of its data could not be read.", comment: "UTMAppleDiskImage") + case .hasSnapshots: + return NSLocalizedString("A drive cannot be resized once a snapshot has been taken of it.", comment: "UTMAppleDiskImage") + case .snapshotsRequireNewerOS: + return NSLocalizedString("This drive holds snapshots and can only be used on macOS 27 or later.", comment: "UTMAppleDiskImage") + case .readOnlyWithSnapshots: + return NSLocalizedString("A drive cannot be read only once a snapshot has been taken of it.", comment: "UTMAppleDiskImage") + } } } 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/UTMQemuImage.swift b/Services/UTMQemuImage.swift index 90959c0e..14e94219 100644 --- a/Services/UTMQemuImage.swift +++ b/Services/UTMQemuImage.swift @@ -173,6 +173,46 @@ import QEMUKitInternal return image_info } + /// Internal snapshots stored in an image + static func snapshots(image url: URL) async throws -> [QemuSnapshotInfo] { + try await info(image: url).snapshots ?? [] + } + + private enum SnapshotOperation: String { + case create = "-c" + case apply = "-a" + case delete = "-d" + } + + private static func snapshot(_ operation: SnapshotOperation, name: String, image url: URL) async throws { + let qemuImg = UTMQemuImage() + let srcBookmark = try url.bookmarkData() + qemuImg.pushArgv("snapshot") + qemuImg.pushArgv(operation.rawValue) + qemuImg.pushArgv(name) + qemuImg.accessData(withBookmark: srcBookmark) + qemuImg.pushArgv(url.path) + let logging = QEMULogging() + logging.delegate = qemuImg + qemuImg.standardOutput = logging.standardOutput + qemuImg.standardError = logging.standardError + try await qemuImg.start() + } + + /// Save the contents of the image as an internal snapshot without any VM state + static func createSnapshot(image url: URL, name: String) async throws { + try await snapshot(.create, name: name, image: url) + } + + /// Revert the contents of the image to an internal snapshot + static func applySnapshot(image url: URL, name: String) async throws { + try await snapshot(.apply, name: name, image: url) + } + + static func deleteSnapshot(image url: URL, name: String) async throws { + try await snapshot(.delete, name: name, image: url) + } + static func size(image url: URL) async throws -> Int64 { try await info(image: url).virtualSize } diff --git a/Services/UTMQemuSnapshotBackend.swift b/Services/UTMQemuSnapshotBackend.swift new file mode 100644 index 00000000..7959eb23 --- /dev/null +++ b/Services/UTMQemuSnapshotBackend.swift @@ -0,0 +1,210 @@ +// +// 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 +import QEMUKitInternal + +/// Snapshot tag that a VM was always suspended to before suspend states could be kept as snapshots +let kUTMQemuDefaultSuspendSnapshotName = "suspend" + +extension UTMQemuVirtualMachine { + /// Images that take part in a snapshot + @MainActor var snapshotImageURLs: [URL] { + var imageURLs = config.drives.compactMap { drive -> URL? in + guard drive.imageType == .disk && !drive.isExternal && !drive.isReadOnly else { + return nil + } + return drive.imageURL + } + // the same condition under which QEMU is given the variable store + if config.qemu.hasUefiBoot, !config.hasCustomBios, let efiVarsURL = config.qemu.efiVarsURL, FileManager.default.fileExists(atPath: efiVarsURL.path) { + imageURLs.insert(efiVarsURL, at: 0) + } + return imageURLs + } +} + +// MARK: - Live + +/// Internal snapshots of a running QEMU, managed through the monitor. +/// +/// QEMU has the images open so nothing else may touch them, least of all qemu-img. It saves and +/// loads the running state along with the disks. A snapshot of just the disks cannot be loaded +/// into a running guest and is left alone until the VM is off. +struct UTMQemuLiveSnapshotBackend: UTMSnapshotBackend { + let vm: UTMQemuVirtualMachine + + let imageURLs: [URL] + + /// QEMU lists the snapshots of every drive together, removable ones included + let isInventoryExact = false + + let defaultSuspendIdentifier = kUTMQemuDefaultSuspendSnapshotName + + func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool { + operation == .create || hasState + } + + func entries() async throws -> [UTMSnapshotBackendEntry] { + guard let monitor = await vm.monitor else { + throw UTMSnapshotError.invalidVmState + } + return try monitor.snapshots().map { snapshot in + UTMSnapshotBackendEntry(identifier: snapshot.name, + date: snapshot.date, + size: snapshot.vmStateSize, + hasState: snapshot.vmStateSize > 0) + } + } + + func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry { + // the state is saved anew because a VM can keep running after it was suspended + let identifier = UUID().uuidString + try await vm.saveSnapshot(name: identifier) + 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 { + try await vm.restoreSnapshot(name: entry.identifier) + return false + } + + func delete(identifier: String) async throws { + try await vm.deleteSnapshot(name: identifier) + } +} + +// MARK: - Frozen + +/// Internal snapshots in the images of a QEMU that is not running, managed through qemu-img. +/// +/// A snapshot spans every image, including the one for the UEFI variables. QEMU saves and loads +/// all of them together and refuses to load a snapshot that any of them is missing. +struct UTMQemuFrozenSnapshotBackend: UTMSnapshotBackend { + let imageURLs: [URL] + + /// Kept from starting while the images change, unless the caller already sees to that + var vm: UTMQemuVirtualMachine? = nil + + let isInventoryExact = true + + let defaultSuspendIdentifier = kUTMQemuDefaultSuspendSnapshotName + + func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool { + true + } + + func entries() async throws -> [UTMSnapshotBackendEntry] { + var entries = [String: UTMSnapshotBackendEntry]() + var imageCounts = [String: Int]() + for imageURL in imageURLs { + for snapshot in try await UTMQemuImage.snapshots(image: imageURL) { + // the VM state is only in one of the images + let previous = entries[snapshot.name] + entries[snapshot.name] = UTMSnapshotBackendEntry(identifier: snapshot.name, + date: max(snapshot.date, previous?.date ?? .distantPast), + size: snapshot.vmStateSize + (previous?.size ?? 0), + hasState: snapshot.vmStateSize > 0 || previous?.hasState == true) + imageCounts[snapshot.name, default: 0] += 1 + } + } + return entries.values.map { entry in + var entry = entry + entry.isComplete = imageCounts[entry.identifier] == imageURLs.count + return entry + } + } + + func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry { + if let suspendIdentifier = suspendIdentifier { + // nothing changed since the VM was suspended + guard let entry = try await entries().first(where: { $0.identifier == suspendIdentifier }), entry.isComplete else { + throw UTMSnapshotError.notFound(suspendIdentifier) + } + return entry + } + for imageURL in imageURLs { + guard try await UTMQemuImage.info(image: imageURL).format == "qcow2" else { + throw UTMSnapshotError.notSupported + } + } + let identifier = UUID().uuidString + try await changingImages(as: .saving) { + do { + for imageURL in imageURLs { + try await UTMQemuImage.createSnapshot(image: imageURL, name: identifier) + } + } catch { + try? await deleteFromImages(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) + } + // the disks are reverted now even when QEMU loads the running state on the next start, + // so they hold the snapshot should that state be discarded instead + try await changingImages(as: .restoring) { + for imageURL in imageURLs { + try await UTMQemuImage.applySnapshot(image: imageURL, name: entry.identifier) + } + } + return entry.hasState + } + + func delete(identifier: String) async throws { + try await changingImages(as: .saving) { + try await deleteFromImages(identifier: identifier) + } + } + + private func deleteFromImages(identifier: String) async throws { + for imageURL in try await imageURLs(containing: identifier) { + try await UTMQemuImage.deleteSnapshot(image: imageURL, name: identifier) + } + } + + private func changingImages(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws { + if let vm = vm { + try await vm.changingImages(as: state, body) + } else { + try await body() + } + } + + private func imageURLs(containing identifier: String) async throws -> [URL] { + var found = [URL]() + for imageURL in imageURLs { + if try await UTMQemuImage.snapshots(image: imageURL).contains(where: { $0.name == identifier }) { + found.append(imageURL) + } + } + guard !found.isEmpty else { + throw UTMSnapshotError.notFound(identifier) + } + return found + } +} diff --git a/Services/UTMQemuVirtualMachine.swift b/Services/UTMQemuVirtualMachine.swift index 148f83a8..c7a1fe2d 100644 --- a/Services/UTMQemuVirtualMachine.swift +++ b/Services/UTMQemuVirtualMachine.swift @@ -21,7 +21,6 @@ import SwiftPortmap #endif private var SpiceIoServiceGuestAgentContext = 0 -private let kSuspendSnapshotName = "suspend" private let kProbeSuspendDelay = 1*NSEC_PER_SEC /// QEMU backend virtual machine @@ -103,8 +102,6 @@ final class UTMQemuVirtualMachine: UTMSpiceVirtualMachine { private(set) var snapshotUnsupportedError: Error? - /// When set, the next `_start` will load this snapshot tag instead of a fresh/suspended boot. - private var pendingRestoreSnapshotName: String? private var isScopedAccess: Bool = false @@ -431,28 +428,21 @@ extension UTMQemuVirtualMachine { try Task.checkCancellation() // load saved state if requested - // - // A user snapshot restore on a stopped VM (`pendingRestoreSnapshotName`) reuses this - // startup path: boot paused, loadvm the requested tag, then continue. Once the VM is - // running, any previous internal suspend state is obsolete and is removed below. let isSuspended = await registryEntry.isSuspended - let userRestoreSnapshotName = pendingRestoreSnapshotName - pendingRestoreSnapshotName = nil - let withMounting: Bool - if let userRestoreSnapshotName = userRestoreSnapshotName { - try await _restoreSnapshot(name: userRestoreSnapshotName) - try Task.checkCancellation() - withMounting = false - } else { - if !isRunningAsDisposible && isSuspended { - try await monitor.qemuRestoreSnapshot(kSuspendSnapshotName) + var isStateMissing = false + if !isRunningAsDisposible && isSuspended { + let suspendSnapshotName = suspendSnapshotName + if try monitor.snapshots().contains(where: { $0.name == suspendSnapshotName }) { + try await _restoreSnapshot(name: suspendSnapshotName) try Task.checkCancellation() + } else { + // a state that is gone leaves the guest to boot fresh, as nothing of it was loaded + isStateMissing = true } - withMounting = !isSuspended } // set up SPICE sharing and removable drives - try await self.restoreExternalDrives(withMounting: withMounting) + try await self.restoreExternalDrives(withMounting: !isSuspended || isStateMissing) if let ioService = interface as? UTMSpiceIO { try await self.restoreSharedDirectory(for: ioService) } else { @@ -463,7 +453,7 @@ extension UTMQemuVirtualMachine { // continue VM boot try await monitor.continueBoot() - // delete saved suspend state (user snapshots are preserved) + // delete saved suspend state if isSuspended { try? await deleteSnapshot() } @@ -622,31 +612,94 @@ extension UTMQemuVirtualMachine { state = prev } do { - try await _saveSnapshot(name: name ?? kSuspendSnapshotName) - if name == nil { + if let name = name { + try await _saveSnapshot(name: name) + } else { + // a new name each time lets the previous one be kept as a snapshot + let suspendName = UUID().uuidString + try await _saveSnapshot(name: suspendName) + let staleNames = try UTMSnapshotManifest.replaceSuspendState(in: pathUrl, with: suspendName) await registryEntry.setIsSuspended(true) try saveScreenshot() + // a state that cannot go now stays listed and goes the next time + try? await deleteStaleSnapshots(staleNames) } } catch { throw UTMQemuVirtualMachineError.saveSnapshotFailed(error) } } + /// Keep the VM from starting while qemu-img changes its images, which corrupts any that QEMU + /// has open. It may have them open without a monitor, so the VM has to be stopped. + func changingImages(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws { + guard self.state == .stopped else { + throw UTMQemuVirtualMachineError.invalidVmState + } + self.state = state + defer { + self.state = .stopped + } + try await body() + } + + /// Snapshot with the state to resume from, older versions always used the same one + private var suspendSnapshotName: String { + UTMSnapshotManifest.suspendState(in: pathUrl) ?? kUTMQemuDefaultSuspendSnapshotName + } + private func _deleteSnapshot(name: String) async throws { if let monitor = await monitor { // if QEMU is running + // QEMU only reports a missing snapshot as a failure, which would leave it listed + guard try monitor.snapshots().contains(where: { $0.name == name }) else { + throw UTMSnapshotError.notFound(name) + } let result = try await monitor.qemuDeleteSnapshot(name) if result.localizedCaseInsensitiveContains("Error") { throw UTMQemuVirtualMachineError.qemuError(result) } - try? updateLastModified() + } else { + try await changingImages(as: .saving) { + try await UTMQemuFrozenSnapshotBackend(imageURLs: await snapshotImageURLs).delete(identifier: name) + } } + try? updateLastModified() } func deleteSnapshot(name: String? = nil) async throws { - if name == nil { - await registryEntry.setIsSuspended(false) + if let name = name { + try await _deleteSnapshot(name: name) + return + } + await registryEntry.setIsSuspended(false) + // the state stays if it was kept as a snapshot + let staleNames = try UTMSnapshotManifest.releaseSuspendState(in: pathUrl, default: kUTMQemuDefaultSuspendSnapshotName) + #if !os(macOS) + // without qemu-img a state can only go while QEMU runs, which the next suspend sees to + guard await monitor != nil else { + return + } + #endif + try await deleteStaleSnapshots(staleNames) + } + + /// Delete states that nothing needs anymore. Each stays listed in the manifest until it is + /// gone so that one that fails is tried again instead of staying in the images for good. + private func deleteStaleSnapshots(_ names: [String]) async throws { + var firstError: Error? + for name in names { + do { + try await _deleteSnapshot(name: name) + } catch UTMSnapshotError.notFound(_) { + // already gone + } catch { + firstError = firstError ?? error + continue + } + try UTMSnapshotManifest.forgetState(name, in: pathUrl) + } + if let error = firstError { + throw error } - try await _deleteSnapshot(name: name ?? kSuspendSnapshotName) } private func _resume() async throws { @@ -684,25 +737,13 @@ extension UTMQemuVirtualMachine { } func restoreSnapshot(name: String? = nil) async throws { - // A stopped VM is restored by booting it directly into the requested snapshot tag, - // reusing the suspend startup path via `pendingRestoreSnapshotName`. - if state == .stopped, let name { - pendingRestoreSnapshotName = name - do { - try await start() - } catch { - pendingRestoreSnapshotName = nil - throw error - } - return - } guard state == .paused || state == .started else { throw UTMQemuVirtualMachineError.invalidVmState } let prev = state state = .restoring do { - try await _restoreSnapshot(name: name ?? kSuspendSnapshotName) + try await _restoreSnapshot(name: name ?? suspendSnapshotName) state = prev } catch { if prev == .started, let monitor = await monitor { diff --git a/Services/UTMSnapshotBackend.swift b/Services/UTMSnapshotBackend.swift new file mode 100644 index 00000000..170f54f2 --- /dev/null +++ b/Services/UTMSnapshotBackend.swift @@ -0,0 +1,85 @@ +// +// 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 + +/// State of a snapshot as it is stored by a backend +struct UTMSnapshotBackendEntry: Equatable { + /// Unique in the backend, what `UTMSnapshotManifest.Snapshot.backendIdentifier` links to + let identifier: String + + let date: Date + + /// Bytes occupied by the snapshot + let size: Int64 + + /// Includes the running state of the VM and not just the disks + let hasState: Bool + + /// Covers every image that takes part in a snapshot. + /// + /// One that does not, such as a snapshot taken before a drive was added, cannot be restored + /// without leaving the drives holding contents from different points in time. + var isComplete: Bool = true + + /// Snapshot this one was taken from, for backends that keep track of it + var parentIdentifier: String? = nil +} + +/// What can be done with the state of a snapshot +enum UTMSnapshotOperation { + /// Save the current state of the VM + case create + /// Replace the current state of the VM + case restore + case delete +} + +/// Where the state of snapshots is kept. There is an implementation for each kind of VM and, +/// where they differ, for a VM that is running (live) and one that is not (frozen). +/// +/// A backend knows nothing about `UTMSnapshotManifest`, use `UTMSnapshotService` instead. +protocol UTMSnapshotBackend { + /// Identifier of the state a suspended VM resumes from when the manifest does not name one + var defaultSuspendIdentifier: String { get } + + /// Where a snapshot is saved, so without any there is nothing to save + var imageURLs: [URL] { get } + + /// `entries()` lists the snapshots of exactly `imageURLs` and knows which are complete, which + /// is needed to add snapshots from the backend or to find those whose state is gone + var isInventoryExact: Bool { get } + + /// An operation is possible while the VM is in the state the backend was picked for. + /// - Parameters: + /// - operation: Operation to perform + /// - hasState: The snapshot includes the running state of the VM, ignored for `.create` + func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool + + /// Every snapshot found in the backend, including those UTM did not create + func entries() async throws -> [UTMSnapshotBackendEntry] + + /// Save the current state of the VM. + /// - Parameter suspendIdentifier: State the VM is suspended to, if it is. It already holds the + /// current state so a backend may return it as the snapshot. + func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry + + /// Replace the current state of the VM with a snapshot. + /// - Returns: True if this only takes effect once the VM resumes from the snapshot when it is started + func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool + + func delete(identifier: String) async throws +} diff --git a/Services/UTMSnapshotService.swift b/Services/UTMSnapshotService.swift index 3454c139..fc5e1d14 100644 --- a/Services/UTMSnapshotService.swift +++ b/Services/UTMSnapshotService.swift @@ -15,138 +15,559 @@ // import Foundation -import Logging -private let snapshotLogger = Logger(label: "com.utmapp.UTM.snapshot") { label in - UTMLoggingSwift(label: label) -} - -/// Coordinates named full-VM snapshots for the QEMU backend. +/// Manages the snapshots of a VM regardless of its backend and of whether it is running. /// -/// This is distinct from the internal suspend/resume feature which uses the reserved name -/// `"suspend"` and `registryEntry.isSuspended`; named snapshots never mark a VM as suspended. +/// The state of a snapshot is kept by a `UTMSnapshotBackend`. Everything else is kept in the +/// `UTMSnapshotManifest` of the bundle which links to that state. Only this service changes the +/// snapshots in a manifest so that the two stay consistent. +/// +/// A snapshot never changes unless it is overwritten. Restoring one replaces the current state of +/// the VM with a copy and the snapshot it was restored from is remembered as what the current +/// state is based on, which is how snapshots get their lineage. +/// +/// Suspending is closely related: the state a VM is suspended to can be kept as a snapshot and +/// restoring a snapshot makes a stopped VM resume from it. The VM itself keeps track of what it +/// resumes from through the suspend state operations of `UTMSnapshotManifest`. @MainActor enum UTMSnapshotService { - /// Snapshot name reserved for the internal suspend/resume feature. - private static let reservedSuspendName = "suspend" + fileprivate typealias Entry = UTMSnapshotManifest.Snapshot - /// Create a named snapshot capturing the full VM state (RAM + devices + disk). + /// Posted with the bundle of the VM as its object whenever its snapshots change + static let didChangeNotification = UTMSnapshotManifest.didChangeNotification + + // MARK: - Reading + + /// Snapshots can be managed for this VM, whether it is running or not + static func isSupported(for vm: any UTMVirtualMachine) -> Bool { + #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 + return false + } + + /// Snapshots of a VM and how the current state relates to them. The first time, those that + /// are already in the backend are added. + static func timeline(for vm: any UTMVirtualMachine) async throws -> UTMSnapshotTimeline { + var manifest = try UTMSnapshotManifest.load(from: vm.pathUrl) ?? UTMSnapshotManifest() + if !manifest.isBackendImported, let backend = try? backend(for: vm), backend.isInventoryExact { + manifest = try await importUnlinkedSnapshots(from: backend, on: vm) + } + return UTMSnapshotTimeline(snapshots: manifest.snapshots.map { UTMSnapshot($0, on: vm) }, + currentParentID: manifest.currentParentID, + isCurrentStateSaved: isCurrentStateSaved(in: manifest, on: vm)) + } + + /// Snapshots of a VM as they are recorded in the bundle, newest first, without consulting the backend + static func snapshots(for vm: any UTMVirtualMachine) -> [UTMSnapshot] { + (((try? UTMSnapshotManifest.load(from: vm.pathUrl)) ?? nil)?.snapshots ?? []).map { UTMSnapshot($0, on: vm) } + } + + /// An operation is possible while the VM is in its current state. /// - /// QEMU replaces an existing snapshot with the same name. - static func createSnapshot(name: String, on vm: any UTMVirtualMachine) async throws { - _ = try requireQemuBackend(vm) - try validate(name: name) - if let error = vm.snapshotUnsupportedError { - throw error + /// Snapshots can be managed whether the VM is running or not, but not everything can be done + /// either way. A running QEMU can only deal with snapshots that include the running state and + /// the disks of a running Apple VM cannot be replaced. + /// - Parameters: + /// - operation: Operation to perform + /// - snapshot: Snapshot to restore or delete + /// - vm: VM the snapshot belongs to + static func isSupported(_ operation: UTMSnapshotOperation, for snapshot: UTMSnapshot? = nil, on vm: any UTMVirtualMachine) -> Bool { + guard let backend = try? backend(for: vm) else { + return false } - // capturing RAM through the QEMU monitor requires the VM to be running or paused - guard vm.state == .started || vm.state == .paused else { - throw UTMSnapshotError.invalidVmState + if operation == .create && backend.imageURLs.isEmpty { + return false } - snapshotLogger.debug("Creating snapshot '\(name)' on QEMU VM") - try await vm.saveSnapshot(name: name) + return backend.supports(operation, hasState: snapshot?.hasState ?? true) } - /// List full-VM snapshots stored in the VM's bundled writable disk images, newest first. - static func listSnapshots(on vm: any UTMVirtualMachine) async throws -> [UTMQemuImage.QemuSnapshotInfo] { - let qemu = try requireQemuBackend(vm) - guard vm.state == .stopped else { - throw UTMSnapshotError.listRequiresStoppedVm + /// Nothing changed since the snapshot the current state is based on, which is known when the + /// VM is suspended to the state of that very snapshot. + /// + /// Saving the current state again would only duplicate that snapshot. + private static func isCurrentStateSaved(in manifest: UTMSnapshotManifest, on vm: any UTMVirtualMachine) -> Bool { + // a VM that is not stopped may have run since it was suspended + guard vm.state == .stopped, let backend = try? backend(for: vm), let parentID = manifest.currentParentID else { + return false } - var snapshotsByName = [String: UTMQemuImage.QemuSnapshotInfo]() - var imageURLs = qemu.config.drives.compactMap { drive -> URL? in - guard drive.imageType == .disk && !drive.isExternal && !drive.isReadOnly else { - return nil + let suspendIdentifier = vm.registryEntry.isSuspended ? manifest.suspendIdentifier ?? backend.defaultSuspendIdentifier : nil + return manifest[parentID]?.backendIdentifier == suspendIdentifier + } + + fileprivate static func screenshotURL(for snapshot: Entry, on vm: any UTMVirtualMachine) -> URL? { + snapshot.screenshotName.map { UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent($0) } + } + + // MARK: - Operations + + /// Save the current state of a VM as a new snapshot. + /// - Parameters: + /// - title: Display name, a default is used if nil + /// - vm: VM that is either running or not + @discardableResult + static func createSnapshot(title: String? = nil, on vm: any UTMVirtualMachine) async throws -> UTMSnapshot { + let backend = try backend(for: vm) + guard !backend.imageURLs.isEmpty else { + throw UTMSnapshotError.noDrives + } + guard backend.supports(.create, hasState: true) else { + throw UTMSnapshotError.requiresStoppedVm + } + let suspendIdentifier = try suspendIdentifier(of: vm, in: backend) + let entry = try await backend.create(suspendIdentifier: suspendIdentifier) + logger.debug("Created snapshot '\(entry.identifier)'") + let screenshotName = saveScreenshot(of: vm) + let isSuspendStateKept = suspendIdentifier != nil && entry.hasState && vm.state != .started + let title = title.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.flatMap { $0.isEmpty ? nil : $0 } + let (snapshot, staleIdentifiers) = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> (Entry, [String]) in + if isSuspendStateKept { + // the VM still resumes from the state which is now the one of the snapshot + manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier) } - return drive.imageURL + let snapshot = Entry(title: title.map { manifest.uniqueTitle($0) } ?? manifest.defaultTitle, + parentID: manifest.currentParentID, + date: entry.date, + size: entry.size, + hasState: entry.hasState, + backendIdentifier: entry.identifier, + screenshotName: screenshotName) + manifest.insert(snapshot) + manifest.currentParentID = snapshot.id + return (snapshot, manifest.staleIdentifiers) } - if qemu.config.qemu.hasUefiBoot, - let efiVarsURL = qemu.config.qemu.efiVarsURL, - FileManager.default.fileExists(atPath: efiVarsURL.path) { - imageURLs.insert(efiVarsURL, at: 0) + await deleteStaleStates(staleIdentifiers, from: backend, on: vm) + return UTMSnapshot(snapshot, on: vm) + } + + /// Replace what a snapshot holds with the current state of a VM. + /// + /// Everything else about the snapshot stays, including the snapshots based on it. + static func overwriteSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws { + let backend = try backend(for: vm) + guard let original = try UTMSnapshotManifest.load(from: vm.pathUrl)?[id] else { + throw UTMSnapshotError.notFound(id.uuidString) } - for imageURL in imageURLs { - let imageInfo = try await UTMQemuImage.info(image: imageURL) - for snapshot in imageInfo.snapshots ?? [] where snapshot.vmStateSize > 0 && snapshot.name != reservedSuspendName { - snapshotsByName[snapshot.name] = snapshot + guard !backend.imageURLs.isEmpty else { + throw UTMSnapshotError.noDrives + } + guard backend.supports(.create, hasState: true) && backend.supports(.delete, hasState: original.hasState) else { + throw UTMSnapshotError.requiresStoppedVm + } + let suspendIdentifier = try suspendIdentifier(of: vm, in: backend) + let entry = try await backend.create(suspendIdentifier: suspendIdentifier) + logger.debug("Overwriting snapshot '\(original.backendIdentifier)' with '\(entry.identifier)'") + let screenshotName = saveScreenshot(of: vm) + let isSuspendStateKept = suspendIdentifier != nil && entry.hasState && vm.state != .started + let staleIdentifiers = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> [String] in + if isSuspendStateKept { + manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier) + } + if var snapshot = manifest[id] { + snapshot.backendIdentifier = entry.identifier + snapshot.size = entry.size + snapshot.hasState = entry.hasState + snapshot.screenshotName = screenshotName + snapshot.dateModified = Date() + snapshot.isOrphaned = false + snapshot.isIncomplete = false + manifest[id] = snapshot + } + manifest.currentParentID = id + manifest.markStale(original.backendIdentifier) + return manifest.staleIdentifiers + } + await deleteStaleStates(staleIdentifiers, from: backend, on: vm) + if let screenshotName = original.screenshotName { + try? FileManager.default.removeItem(at: UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent(screenshotName)) + } + } + + /// - Returns: The snapshot with the title it ended up with + @discardableResult + static func renameSnapshot(_ id: UUID, to title: String, on vm: any UTMVirtualMachine) throws -> UTMSnapshot { + let title = title.trimmingCharacters(in: .whitespacesAndNewlines) + let snapshot = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> Entry in + guard var snapshot = manifest[id] else { + throw UTMSnapshotError.notFound(id.uuidString) + } + guard !title.isEmpty && snapshot.title != title else { + return snapshot + } + // a snapshot is addressed by its title outside of the app, so keep them apart + snapshot.title = manifest.uniqueTitle(title, excluding: id) + snapshot.dateModified = Date() + manifest[id] = snapshot + return snapshot + } + return UTMSnapshot(snapshot, on: vm) + } + + /// Delete a snapshot, along with its state once nothing else links to it. + static func deleteSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws { + let backend = try backend(for: vm) + guard let manifest = try UTMSnapshotManifest.load(from: vm.pathUrl), let snapshot = manifest[id] else { + throw UTMSnapshotError.notFound(id.uuidString) + } + guard backend.supports(.delete, hasState: snapshot.hasState) else { + throw UTMSnapshotError.requiresStoppedVm + } + var remaining = manifest + remaining.remove(id) + if !remaining.isReferenced(backendIdentifier: snapshot.backendIdentifier) { + do { + try await backend.delete(identifier: snapshot.backendIdentifier) + logger.debug("Deleted snapshot '\(snapshot.backendIdentifier)'") + } catch UTMSnapshotError.notFound(_) { + // orphaned + } catch { + // some drives may have lost the state already, so it must not look restorable + _ = try? await identifyOrphanedSnapshots(on: vm) + throw error } } - return snapshotsByName.values.sorted { $0.date > $1.date } + try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in + manifest.remove(id) + } + if let screenshotName = snapshot.screenshotName { + try? FileManager.default.removeItem(at: UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent(screenshotName)) + } } - /// Restore the VM to a previously captured named snapshot. - static func restoreSnapshot(name: String, on vm: any UTMVirtualMachine) async throws { - _ = try requireQemuBackend(vm) - try validate(name: name) - guard vm.state == .stopped else { - throw UTMSnapshotError.restoreRequiresStoppedVm + /// Replace the current state of a VM with a snapshot. + /// + /// A running VM continues from the snapshot right away. Otherwise it resumes from it, or boots + /// from its disks if the snapshot has no running state, the next time it is started. + static func restoreSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws { + let backend = try backend(for: vm) + guard let snapshot = try UTMSnapshotManifest.load(from: vm.pathUrl)?[id] else { + throw UTMSnapshotError.notFound(id.uuidString) } - guard try await listSnapshots(on: vm).contains(where: { $0.name == name }) else { - throw UTMSnapshotError.notFound(name) + guard backend.supports(.restore, hasState: snapshot.hasState) else { + throw UTMSnapshotError.requiresStoppedVm } - snapshotLogger.debug("Restoring snapshot '\(name)' on QEMU VM") - try await vm.restoreSnapshot(name: name) + guard let entry = try await backend.entries().first(where: { $0.identifier == snapshot.backendIdentifier }) else { + try? await identifyOrphanedSnapshots(on: vm) + throw UTMSnapshotError.orphaned(snapshot.title) + } + guard entry.isComplete else { + try? await identifyOrphanedSnapshots(on: vm) + throw UTMSnapshotError.incomplete(snapshot.title) + } + // a state left named after a start that failed is replaced as well + let previousSuspendIdentifier = try suspendIdentifier(of: vm, in: backend) + let isResumedOnStart = try await backend.restore(entry) + logger.debug("Restored snapshot '\(entry.identifier)'") + let staleIdentifiers = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> [String] in + manifest.replaceSuspendState(previousSuspendIdentifier, with: isResumedOnStart ? entry.identifier : nil) + manifest.currentParentID = id + return manifest.staleIdentifiers + } + vm.registryEntry.isSuspended = isResumedOnStart + // the state that was suspended to is replaced + await deleteStaleStates(staleIdentifiers, from: backend, on: vm) + // the current state is a copy of the snapshot now, so show what the snapshot shows + restoreScreenshot(of: snapshot, on: vm) } - /// Delete a named snapshot and its backend state. - static func deleteSnapshot(name: String, on vm: any UTMVirtualMachine) async throws { - _ = try requireQemuBackend(vm) - try validate(name: name) - // QEMU deletes the tag from the running qcow2 via the monitor, so it must be running. - if vm.state != .started && vm.state != .paused { - throw UTMSnapshotError.invalidVmState + // MARK: - Consistency with the backend + + /// Find snapshots whose state is no longer in the backend, such as after another tool + /// modified it, and mark them in the manifest. + /// + /// Call this when something that depends on the state of a snapshot fails. + static func identifyOrphanedSnapshots(on vm: any UTMVirtualMachine) async throws { + let backend = try backend(for: vm) + guard backend.isInventoryExact else { + return } - snapshotLogger.debug("Deleting snapshot '\(name)' on QEMU VM") - try await vm.deleteSnapshot(name: name) + let entries = try await backend.entries() + try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in + for index in manifest.snapshots.indices { + let entry = entries.first { $0.identifier == manifest.snapshots[index].backendIdentifier } + manifest.snapshots[index].isOrphaned = entry == nil + // the state is there but does not cover every drive, which is not the same as gone + manifest.snapshots[index].isIncomplete = entry.map { !$0.isComplete } ?? false + } + manifest.forgetStaleStates(notIn: entries) + } + } + + /// Add a snapshot for every state in the backend that none links to, such as those made by + /// another tool, and refresh what the backend knows about the others. + /// + /// Anything the backend does not store is generated. + @discardableResult + private static func importUnlinkedSnapshots(from backend: any UTMSnapshotBackend, on vm: any UTMVirtualMachine) async throws -> UTMSnapshotManifest { + let entries = try await backend.entries().sorted { $0.date < $1.date } + return try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in + for index in manifest.snapshots.indices { + if let entry = entries.first(where: { $0.identifier == manifest.snapshots[index].backendIdentifier }) { + manifest.snapshots[index].size = entry.size + manifest.snapshots[index].hasState = entry.hasState + manifest.snapshots[index].isOrphaned = false + manifest.snapshots[index].isIncomplete = !entry.isComplete + } + } + manifest.forgetStaleStates(notIn: entries) + for entry in entries where !manifest.isReferenced(backendIdentifier: entry.identifier) { + guard entry.identifier != backend.defaultSuspendIdentifier && !manifest.staleIdentifiers.contains(entry.identifier) else { + // only ever used for suspending, or waiting to be deleted + continue + } + logger.debug("Importing snapshot '\(entry.identifier)'") + // an identifier that is not generated was chosen by someone + let title = UUID(uuidString: entry.identifier) == nil ? manifest.uniqueTitle(entry.identifier) : manifest.defaultTitle + let parent = manifest.snapshots.first { $0.backendIdentifier == entry.parentIdentifier } + var snapshot = Entry(title: title, + parentID: parent?.id, + date: entry.date, + size: entry.size, + hasState: entry.hasState, + backendIdentifier: entry.identifier) + snapshot.isIncomplete = !entry.isComplete + manifest.insert(snapshot) + } + manifest.isBackendImported = true + return manifest + } + } + + // MARK: - Reconciling with the backend + + /// Bring the manifest in line with what the backend holds. + /// + /// Snapshots made by other tools are added and snapshots whose state has disappeared are + /// marked. The snapshots tab does this the first time a VM is opened; everywhere else it has + /// to be asked for, because it reads every image. + static func scanSnapshots(on vm: any UTMVirtualMachine) async throws { + let backend = try backend(for: vm) + guard backend.isInventoryExact else { + throw UTMSnapshotError.requiresStoppedVm + } + try await importUnlinkedSnapshots(from: backend, on: vm) + try await identifyOrphanedSnapshots(on: vm) } // MARK: - Helpers - private static func requireQemuBackend(_ vm: any UTMVirtualMachine) throws -> UTMQemuVirtualMachine { - guard let qemu = vm as? UTMQemuVirtualMachine else { + /// Pick the backend for the kind of VM and for whether it is running + private static func backend(for vm: any UTMVirtualMachine) throws -> any UTMSnapshotBackend { + guard !vm.isRunningAsDisposible else { throw UTMSnapshotError.notSupported } - return qemu + guard isSupported(for: vm) else { + throw UTMSnapshotError.notSupported + } + if let qemu = vm as? UTMQemuVirtualMachine { + switch vm.state { + case .started, .paused: return UTMQemuLiveSnapshotBackend(vm: qemu, imageURLs: qemu.snapshotImageURLs) + case .stopped: return UTMQemuFrozenSnapshotBackend(imageURLs: qemu.snapshotImageURLs, vm: qemu) + 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 } - private static func validate(name: String) throws { - if name.caseInsensitiveCompare(reservedSuspendName) == .orderedSame { - throw UTMSnapshotError.reservedName(name) + /// State the VM is suspended to, if it is + private static func suspendIdentifier(of vm: any UTMVirtualMachine, in backend: any UTMSnapshotBackend) throws -> String? { + guard vm.registryEntry.isSuspended else { + return nil } - let pattern = "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$" - guard name.range(of: pattern, options: .regularExpression) != nil else { - throw UTMSnapshotError.invalidName(name) + return try UTMSnapshotManifest.load(from: vm.pathUrl)?.suspendIdentifier ?? backend.defaultSuspendIdentifier + } + + /// Delete states that nothing needs anymore. Each stays listed in the manifest until it is + /// gone so that one that fails, such as one a running VM cannot delete, is tried again the + /// next time instead of staying in the backend for good. + private static func deleteStaleStates(_ identifiers: [String], from backend: any UTMSnapshotBackend, on vm: any UTMVirtualMachine) async { + for identifier in identifiers { + do { + try await backend.delete(identifier: identifier) + logger.debug("Deleted stale state '\(identifier)'") + } catch UTMSnapshotError.notFound(_) { + // already gone + } catch { + logger.debug("Keeping stale state '\(identifier)': \(error)") + continue + } + try? UTMSnapshotManifest.forgetState(identifier, in: vm.pathUrl) } } + + /// Keep the most recent screenshot of the VM for a snapshot. + /// - Returns: Name of the file in the screenshots directory + private static func saveScreenshot(of vm: any UTMVirtualMachine) -> String? { + let fileManager = FileManager.default + let screenshotsURL = UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl) + let name = UUID().uuidString + ".png" + if vm.state != .stopped { + try? vm.saveScreenshot() + } + do { + try fileManager.createDirectory(at: screenshotsURL, withIntermediateDirectories: true) + try fileManager.copyItem(at: vm.pathUrl.appendingPathComponent(kUTMBundleScreenshotFilename), to: screenshotsURL.appendingPathComponent(name)) + return name + } catch { + return nil + } + } + + /// Show what a snapshot shows for the current state of a VM. + /// + /// A snapshot taken while the screen was not being kept has no picture of its own, and then + /// the one on show belongs to a state the VM is no longer in, so it goes. + private static func restoreScreenshot(of snapshot: Entry, on vm: any UTMVirtualMachine) { + let fileManager = FileManager.default + let currentURL = vm.pathUrl.appendingPathComponent(kUTMBundleScreenshotFilename) + try? fileManager.removeItem(at: currentURL) + if let screenshotURL = screenshotURL(for: snapshot, on: vm), fileManager.fileExists(atPath: screenshotURL.path) { + try? fileManager.copyItem(at: screenshotURL, to: currentURL) + } + try? vm.reloadScreenshotFromFile() + } +} + +// MARK: - Read model + +/// A snapshot of a VM as it is shown and scripted +struct UTMSnapshot: Identifiable, Equatable { + let id: UUID + + /// User-editable display name + var title: String + + /// Snapshot the current state was based on when this one was created, `nil` if there was none + var parentID: UUID? + + var dateCreated: Date + + /// Last time the snapshot was renamed or overwritten + var dateModified: Date + + /// Bytes occupied in the backend + var size: Int64 + + /// Includes the running state of the VM, otherwise it starts up from the saved disks + var hasState: Bool + + /// The saved state is missing so the snapshot can only be deleted + var isOrphaned: Bool + + /// The saved state does not cover every drive so the snapshot cannot be restored + var isIncomplete: Bool + + /// Screen contents when the snapshot was taken, if one was captured + var screenshotURL: URL? + + @MainActor + fileprivate init(_ entry: UTMSnapshotManifest.Snapshot, on vm: any UTMVirtualMachine) { + id = entry.id + title = entry.title + parentID = entry.parentID + dateCreated = entry.dateCreated + dateModified = entry.dateModified + size = entry.size + hasState = entry.hasState + isOrphaned = entry.isOrphaned + isIncomplete = entry.isIncomplete + screenshotURL = UTMSnapshotService.screenshotURL(for: entry, on: vm) + } +} + +/// The snapshots of a VM along with the current state they are listed under +struct UTMSnapshotTimeline: Equatable { + /// Newest first + var snapshots: [UTMSnapshot] + + /// Snapshot the current state descends from, `nil` if there is none + var currentParentID: UUID? + + /// Nothing changed since the snapshot the current state is based on + var isCurrentStateSaved: Bool +} + +private extension UTMSnapshotManifest { + /// Stop resuming from `previous`, the state the VM was found suspended to, which the manifest + /// may not name when it was suspended by an older version + mutating func replaceSuspendState(_ previous: String?, with backendIdentifier: String?) { + if suspendIdentifier == nil { + suspendIdentifier = previous + } + setSuspendState(backendIdentifier) + } + + /// States that were to be deleted and are gone from an exact inventory need no deleting + mutating func forgetStaleStates(notIn entries: [UTMSnapshotBackendEntry]) { + staleIdentifiers.removeAll { identifier in !entries.contains { $0.identifier == identifier } } + } + + var defaultTitle: String { + var number = snapshots.count + 1 + var title: String + repeat { + title = String.localizedStringWithFormat(NSLocalizedString("Snapshot %lld", comment: "UTMSnapshotService"), number) + number += 1 + } while snapshots.contains { $0.title == title } + return title + } + + /// - Parameter id: Snapshot that may keep the title it already has + func uniqueTitle(_ base: String, excluding id: UUID? = nil) -> String { + var title = base + var number = 2 + while snapshots.contains(where: { $0.title == title && $0.id != id }) { + title = "\(base) \(number)" + number += 1 + } + return title + } } enum UTMSnapshotError: Error { - case reservedName(String) - case invalidName(String) case notFound(String) + case orphaned(String) + case incomplete(String) + case noDrives case notSupported case invalidVmState - case listRequiresStoppedVm - case restoreRequiresStoppedVm + case requiresStoppedVm } extension UTMSnapshotError: LocalizedError { var errorDescription: String? { switch self { - case .reservedName(let name): - return String.localizedStringWithFormat(NSLocalizedString("The name '%@' is reserved and cannot be used for a snapshot.", comment: "UTMSnapshotService"), name) - case .invalidName(let name): - return String.localizedStringWithFormat(NSLocalizedString("The name '%@' is not a valid snapshot name. Use letters, numbers, and the characters _.- (up to 64 characters).", comment: "UTMSnapshotService"), name) case .notFound(let name): return String.localizedStringWithFormat(NSLocalizedString("The snapshot '%@' does not exist.", comment: "UTMSnapshotService"), name) + case .orphaned(let name): + return String.localizedStringWithFormat(NSLocalizedString("The data of the snapshot '%@' is missing. It may have been removed by another application.", comment: "UTMSnapshotService"), name) + case .incomplete(let name): + return String.localizedStringWithFormat(NSLocalizedString("The snapshot '%@' cannot be restored because it does not cover every drive of the virtual machine, such as a drive added after it was taken.", comment: "UTMSnapshotService"), name) + case .noDrives: + return NSLocalizedString("A snapshot needs a drive that the virtual machine can write to.", comment: "UTMSnapshotService") case .notSupported: return NSLocalizedString("Snapshots are not supported for this virtual machine.", comment: "UTMSnapshotService") case .invalidVmState: return NSLocalizedString("The virtual machine is in an invalid state for this snapshot operation.", comment: "UTMSnapshotService") - case .listRequiresStoppedVm: - return NSLocalizedString("The virtual machine must be stopped before listing snapshots.", comment: "UTMSnapshotService") - case .restoreRequiresStoppedVm: - return NSLocalizedString("The virtual machine must be stopped before restoring a snapshot.", comment: "UTMSnapshotService") + case .requiresStoppedVm: + return NSLocalizedString("The virtual machine must be stopped for this snapshot operation.", comment: "UTMSnapshotService") } } } diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 57bac1b4..1d47b29e 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -140,10 +140,19 @@ 8453DCB2278CE3D10037A0DA /* qemu-img.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = 8453DCB0278CE33E0037A0DA /* qemu-img.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 8453DCB4278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; }; 393A92DAE84A47029BA5F829 /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; }; + 738DDF2A88F46375A0D72177 /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; }; + EFAB3C24B8C39BC85EEFE028 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; }; + AFC3FBE9C92083C52D800437 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; }; 8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; }; 41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; }; + 8F8F6CA6377BBED5737019BB /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; }; + 12618B76026270B5C1C8CD29 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; }; + BCB19D49AC1352354193ACB6 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; }; 8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; }; FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; }; + 63353664DA55B4F6AC8BD07B /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; }; + 6E14FA50A12736E32EA02200 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; }; + E1B70310A596175A0E9F3025 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; }; 845F1705289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1704289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift */; }; 845F1707289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */; }; 845F1709289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1708289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift */; }; @@ -168,8 +177,11 @@ 8471772927CD3CAB00D3A50B /* DetailedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471772727CD3CAB00D3A50B /* DetailedSection.swift */; }; 8471772A27CD3CAB00D3A50B /* DetailedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471772727CD3CAB00D3A50B /* DetailedSection.swift */; }; 847BF9AA2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; }; + 29B2D623BB879D2653CA8C53 /* VMSnapshotData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */; }; 847BF9AB2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; }; + 10488F379FF913D2E7770E60 /* VMSnapshotData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */; }; 847BF9AC2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; }; + A5A9A26C444286951EC315D9 /* VMSnapshotData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */; }; 84818C0C2898A07A009EDB67 /* AVFAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84818C0B2898A07A009EDB67 /* AVFAudio.framework */; }; 84818C0D2898A07F009EDB67 /* AVFAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84818C0B2898A07A009EDB67 /* AVFAudio.framework */; }; 848308D5278A1F2200E3E474 /* Virtualization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 848308D4278A1F2200E3E474 /* Virtualization.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; @@ -615,7 +627,9 @@ CE2D956A24AD4F990059923A /* VMPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954224AD4F980059923A /* VMPlaceholderView.swift */; }; CE2D956C24AD4F990059923A /* VMCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954324AD4F980059923A /* VMCardView.swift */; }; CE2D956F24AD4F990059923A /* VMRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */; }; + F3B87AB8E7FA8B9648FC0EAD /* VMSnapshotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */; }; CE2D957024AD4F990059923A /* VMRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */; }; + D38CFF45AB0566027A30677F /* VMSnapshotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */; }; CE2D957124AD4F990059923A /* UTMExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954624AD4F980059923A /* UTMExtensions.swift */; }; CE2D957224AD4F990059923A /* UTMExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954624AD4F980059923A /* UTMExtensions.swift */; }; CE2D957324AD4F990059923A /* VMConfigSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954724AD4F980059923A /* VMConfigSharingView.swift */; }; @@ -673,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 */; }; @@ -752,6 +767,7 @@ CEA3EE05301691C500AAA8E4 /* QEMURenderServer.app in Embed Executables */ = {isa = PBXBuildFile; fileRef = CEA3EDFA30168B9000AAA8E4 /* QEMURenderServer.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; CEA45E25263519B5002FA97D /* VMDisplayMetalViewController+Pointer.h in Sources */ = {isa = PBXBuildFile; fileRef = 83FBDD53242FA71900D2C5D7 /* VMDisplayMetalViewController+Pointer.h */; }; CEA45E27263519B5002FA97D /* VMRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */; }; + E427638537FF5935C6DCAF11 /* VMSnapshotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */; }; CEA45E37263519B5002FA97D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE772AAB25C8B0F600E4E379 /* ContentView.swift */; }; CEA45E3A263519B5002FA97D /* UTMLegacyQemuConfiguration+System.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5425332437C22A00E520F7 /* UTMLegacyQemuConfiguration+System.m */; }; CEA45E3F263519B5002FA97D /* UTMProcess.m in Sources */ = {isa = PBXBuildFile; fileRef = CE9D197B226542FE00355E14 /* UTMProcess.m */; }; @@ -958,6 +974,7 @@ CEC1B00C2BBB211C0088119D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */; }; CEC1B00D2BBB211C0088119D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */; }; CEC794BA294924E300121A9F /* UTMScriptingSerialPortImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */; }; + FC6D2EA0328DA5CD033B9518 /* UTMScriptingSnapshotImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */; }; CEC794BC2949663C00121A9F /* UTMScripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794BB2949663C00121A9F /* UTMScripting.swift */; }; CEC794BD2949663C00121A9F /* UTMScripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794BB2949663C00121A9F /* UTMScripting.swift */; }; CED234ED254796E500ED0A57 /* NumberTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED234EC254796E500ED0A57 /* NumberTextField.swift */; }; @@ -1783,6 +1800,9 @@ 8453DCB0278CE33E0037A0DA /* qemu-img.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "qemu-img.framework"; path = "$(SYSROOT_DIR)/Frameworks/qemu-img.framework"; sourceTree = ""; }; 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMQemuImage.swift; sourceTree = ""; }; EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSnapshotService.swift; sourceTree = ""; }; + F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMQemuSnapshotBackend.swift; sourceTree = ""; }; + DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSnapshotBackend.swift; sourceTree = ""; }; + 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSnapshotManifest.swift; sourceTree = ""; }; 845F1704289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleConfigurationGenericPlatform.swift; sourceTree = ""; }; 845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMAppleSettingsAddDeviceMenuView.swift; sourceTree = ""; }; 845F1708289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayAppleTerminalWindowController.swift; sourceTree = ""; }; @@ -1796,6 +1816,7 @@ 8471770527CC974F00D3A50B /* DefaultTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTextField.swift; sourceTree = ""; }; 8471772727CD3CAB00D3A50B /* DetailedSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailedSection.swift; sourceTree = ""; }; 847BF9A92A49C783000BD9AA /* VMData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMData.swift; sourceTree = ""; }; + 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSnapshotData.swift; sourceTree = ""; }; 84818C0B2898A07A009EDB67 /* AVFAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFAudio.framework; path = System/Library/Frameworks/AVFAudio.framework; sourceTree = SDKROOT; }; 848308D4278A1F2200E3E474 /* Virtualization.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Virtualization.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.1.sdk/System/Library/Frameworks/Virtualization.framework; sourceTree = DEVELOPER_DIR; }; 848A98AF286A0F74006F0550 /* UTMAppleConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleConfiguration.swift; sourceTree = ""; }; @@ -1987,6 +2008,7 @@ CE2D954224AD4F980059923A /* VMPlaceholderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMPlaceholderView.swift; sourceTree = ""; }; CE2D954324AD4F980059923A /* VMCardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMCardView.swift; sourceTree = ""; }; CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMRemovableDrivesView.swift; sourceTree = ""; }; + B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMSnapshotsView.swift; sourceTree = ""; }; CE2D954624AD4F980059923A /* UTMExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UTMExtensions.swift; sourceTree = ""; }; CE2D954724AD4F980059923A /* VMConfigSharingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMConfigSharingView.swift; sourceTree = ""; }; CE2D954824AD4F980059923A /* VMConfigInputView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMConfigInputView.swift; sourceTree = ""; }; @@ -2040,6 +2062,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 = ""; }; @@ -2145,6 +2168,7 @@ CEC0A3092A7490D200980857 /* VMConfigQEMUArgumentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigQEMUArgumentsView.swift; sourceTree = ""; }; CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingSerialPortImpl.swift; sourceTree = ""; }; + 38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingSnapshotImpl.swift; sourceTree = ""; }; CEC794BB2949663C00121A9F /* UTMScripting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UTMScripting.swift; sourceTree = ""; }; CEC9968328AA516000E7A025 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = ""; }; CECF02562B706ADD00409FC0 /* UTMRemoteConnectInterface.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMRemoteConnectInterface.h; sourceTree = ""; }; @@ -2786,6 +2810,7 @@ 835AA7B026AB7C85007A0411 /* UTMPendingVirtualMachine.swift */, CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */, 847BF9A92A49C783000BD9AA /* VMData.swift */, + 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */, CE550BD52259479D0063E575 /* Assets.xcassets */, 521F3EFB2414F73800130500 /* Localizable.strings */, CED8DF7928A120C100C34345 /* Localizable.stringsdict */, @@ -2901,6 +2926,7 @@ 848A98B5286A142C006F0550 /* UTMAppleConfigurationSerial.swift */, 848A98B3286A1215006F0550 /* UTMAppleConfigurationVirtualization.swift */, 841619A5284315C1000034B2 /* UTMQemuConfiguration.swift */, + 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */, 848D99C328670F650055C215 /* UTMQemuConfiguration+Arguments.swift */, 843BF82328441EAD0029D60D /* UTMQemuConfigurationDisplay.swift */, 8443EFF12845641600B2E6E2 /* UTMQemuConfigurationDrive.swift */, @@ -2983,6 +3009,8 @@ CE9D197B226542FE00355E14 /* UTMProcess.m */, 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */, EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */, + F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */, + DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */, 84A0A8822A47D52E0038F329 /* UTMQemuPort.swift */, CE03D05424D90BE000F76B84 /* UTMQemuSystem.h */, CE03D05024D90B4E00F76B84 /* UTMQemuSystem.m */, @@ -2999,6 +3027,7 @@ CE7A1C012EFA0A01001671E9 /* UTMAppleUSBDevice.swift */, CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */, CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */, + 0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */, CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */, CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */, 4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */, @@ -3143,6 +3172,7 @@ 8432328F28C2CDAD00CFBC97 /* VMNavigationListView.swift */, CE2D954224AD4F980059923A /* VMPlaceholderView.swift */, CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */, + B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */, 84C4D9012880CA8A00EC3B2B /* VMSettingsAddDeviceMenuView.swift */, CE8813D424CD265700532628 /* VMShareFileModifier.swift */, CE2D953824AD4F980059923A /* VMToolbarModifier.swift */, @@ -3197,6 +3227,7 @@ CE59A7AF2AA69AE400E5FFBD /* UTMScriptingUSBDeviceImpl.swift */, CEFE98E029485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift */, CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */, + 38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */, CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */, CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */, CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */, @@ -3807,10 +3838,12 @@ CE231D462BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */, 84C4D9022880CA8A00EC3B2B /* VMSettingsAddDeviceMenuView.swift in Sources */, CE2D956F24AD4F990059923A /* VMRemovableDrivesView.swift in Sources */, + F3B87AB8E7FA8B9648FC0EAD /* VMSnapshotsView.swift in Sources */, 8443EFF22845641600B2E6E2 /* UTMQemuConfigurationDrive.swift in Sources */, 8443EFFA28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */, CE772AAC25C8B0F600E4E379 /* ContentView.swift in Sources */, 847BF9AA2A49C783000BD9AA /* VMData.swift in Sources */, + 29B2D623BB879D2653CA8C53 /* VMSnapshotData.swift in Sources */, CE2D927A24AD46670059923A /* UTMLegacyQemuConfiguration+System.m in Sources */, 843BF8302844853E0029D60D /* UTMQemuConfigurationNetwork.swift in Sources */, CE2D927C24AD46670059923A /* UTMProcess.m in Sources */, @@ -3899,6 +3932,9 @@ 848D99A8285DB5550055C215 /* VMConfigConstantPicker.swift in Sources */, 8453DCB4278CE5410037A0DA /* UTMQemuImage.swift in Sources */, 393A92DAE84A47029BA5F829 /* UTMSnapshotService.swift in Sources */, + 738DDF2A88F46375A0D72177 /* UTMQemuSnapshotBackend.swift in Sources */, + EFAB3C24B8C39BC85EEFE028 /* UTMSnapshotBackend.swift in Sources */, + AFC3FBE9C92083C52D800437 /* UTMSnapshotManifest.swift in Sources */, CE2D955924AD4F980059923A /* VMToolbarModifier.swift in Sources */, CE2D92DA24AD46670059923A /* VMCursor.m in Sources */, CE9375A124BBDDD10074066F /* VMConfigDriveDetailsView.swift in Sources */, @@ -4031,6 +4067,7 @@ CE9375A224BBDDD10074066F /* VMConfigDriveDetailsView.swift in Sources */, CE2D955824AD4F980059923A /* VMConfigDisplayView.swift in Sources */, CEC794BA294924E300121A9F /* UTMScriptingSerialPortImpl.swift in Sources */, + FC6D2EA0328DA5CD033B9518 /* UTMScriptingSnapshotImpl.swift in Sources */, CE03D05324D90B4E00F76B84 /* UTMQemuSystem.m in Sources */, CE88A1602E24B2B400EAA28E /* UTMInputIntent.swift in Sources */, 841619B82843226B000034B2 /* QEMUConstant.swift in Sources */, @@ -4135,6 +4172,7 @@ CEFE96772B69A7CC000F00C9 /* VMRemoteSessionState.swift in Sources */, CEEF26A72CEDAEEA003F7B8C /* UTMDownloadMacSupportToolsTask.swift in Sources */, CE2D957024AD4F990059923A /* VMRemovableDrivesView.swift in Sources */, + D38CFF45AB0566027A30677F /* VMSnapshotsView.swift in Sources */, CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */, CE0B6CFE24AD56AE00FE012D /* UTMLogging.m in Sources */, 843BF82E284482C10029D60D /* UTMQemuConfigurationInput.swift in Sources */, @@ -4145,6 +4183,9 @@ CE2D956224AD4F990059923A /* VMSettingsView.swift in Sources */, 8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */, FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */, + 63353664DA55B4F6AC8BD07B /* UTMQemuSnapshotBackend.swift in Sources */, + 6E14FA50A12736E32EA02200 /* UTMSnapshotBackend.swift in Sources */, + E1B70310A596175A0E9F3025 /* UTMSnapshotManifest.swift in Sources */, CEDF83FA258AE24E0030E4AC /* UTMPasteboard.swift in Sources */, 848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */, 848A98BA286A17A8006F0550 /* UTMAppleConfigurationNetwork.swift in Sources */, @@ -4173,6 +4214,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 */, @@ -4182,6 +4224,7 @@ CE9B153F2B11A63E003A32DD /* UTMRemoteServer.swift in Sources */, 846F8D5A2E3891FE0037162B /* UTMKeyboardShortcuts.swift in Sources */, 847BF9AC2A49C783000BD9AA /* VMData.swift in Sources */, + A5A9A26C444286951EC315D9 /* VMSnapshotData.swift in Sources */, CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */, CE2D958824AD4F990059923A /* VMConfigPortForwardForm.swift in Sources */, 03FA9C752B9BBDB000C53A5A /* UTMConfigurationHostNetwork.swift in Sources */, @@ -4207,6 +4250,7 @@ CEA45E25263519B5002FA97D /* VMDisplayMetalViewController+Pointer.h in Sources */, 84C505AD28C588EC007CE8FF /* SizeTextField.swift in Sources */, CEA45E27263519B5002FA97D /* VMRemovableDrivesView.swift in Sources */, + E427638537FF5935C6DCAF11 /* VMSnapshotsView.swift in Sources */, CEF0306E26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */, CE231D432BDDF280006D6DC3 /* UTMDonateView.swift in Sources */, CE65BABF26A4D8DD0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */, @@ -4287,6 +4331,9 @@ 848A98C5286F332D006F0550 /* UTMConfiguration.swift in Sources */, 8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */, 41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */, + 8F8F6CA6377BBED5737019BB /* UTMQemuSnapshotBackend.swift in Sources */, + 12618B76026270B5C1C8CD29 /* UTMSnapshotBackend.swift in Sources */, + BCB19D49AC1352354193ACB6 /* UTMSnapshotManifest.swift in Sources */, 84C60FBB268269D700B58C00 /* VMDisplayViewController.swift in Sources */, 8443EFFB28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */, 848D99BD28636AC90055C215 /* UTMConfigurationDrive.swift in Sources */, @@ -4345,6 +4392,7 @@ CEF0304F26A2AFBF00667B63 /* BigButtonStyle.swift in Sources */, 84018691288A73300050AC51 /* VMDisplayViewController.m in Sources */, 847BF9AB2A49C783000BD9AA /* VMData.swift in Sources */, + 10488F379FF913D2E7770E60 /* VMSnapshotData.swift in Sources */, 84909A8E27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */, CEA45EF4263519B5002FA97D /* VMConfigSoundView.swift in Sources */, 8432329928C3017F00CFBC97 /* GlobalFileImporter.swift in Sources */, diff --git a/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b819e38a..850e1360 100644 --- a/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -52,7 +52,7 @@ "location" : "https://github.com/utmapp/QEMUKit.git", "state" : { "branch" : "main", - "revision" : "589765abff27a8764d58b1a90999a204ac09881e" + "revision" : "14f445a7333edc7113f9e37f6d2915b1c5ca00b8" } }, { diff --git a/utmctl/UTMCtl.swift b/utmctl/UTMCtl.swift index 7d6cc34d..478faa1a 100644 --- a/utmctl/UTMCtl.swift +++ b/utmctl/UTMCtl.swift @@ -37,6 +37,7 @@ struct UTMCtl: ParsableCommand { IPAddress.self, Clone.self, Delete.self, + DiscardState.self, USB.self, Snapshot.self ] @@ -91,6 +92,29 @@ extension UTMAPICommand { } } + /// Snapshots of a virtual machine, newest first + /// - Parameter vm: Virtual machine + /// - Returns: Snapshots of the virtual machine + func snapshots(of vm: UTMScriptingVirtualMachine) -> [UTMScriptingSnapshot] { + (vm.snapshots!() as? [UTMScriptingSnapshot]) ?? [] + } + + /// Get a snapshot of a virtual machine from an identifier + /// - Parameters: + /// - identifier: Name or identifier of the snapshot + /// - vm: Virtual machine the snapshot belongs to + /// - Returns: Snapshot for identifier + func snapshot(forIdentifier identifier: UTMCtl.SnapshotIdentifier, of vm: UTMScriptingVirtualMachine) throws -> UTMScriptingSnapshot { + let snapshots = snapshots(of: vm) + if let snapshot = snapshots.first(where: { $0.id!() == identifier.snapshot }) { + return snapshot + } else if let snapshot = snapshots.first(where: { $0.name! == identifier.snapshot }) { + return snapshot + } else { + throw UTMCtl.APIError.snapshotNotFound + } + } + /// Find the path to UTM.app private var utmAppUrl: URL { if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() { @@ -114,6 +138,9 @@ extension UTMAPICommand { extension UTMCtl { @objc class EventErrorHandler: NSObject, SBApplicationDelegate { static let shared = EventErrorHandler() + + /// An event failed, so what it returned must not be used + private(set) var hasFailed = false /// Error handler for scripting events /// - Parameters: @@ -121,6 +148,7 @@ extension UTMCtl { /// - error: Error /// - Returns: nil func eventDidFail(_ event: UnsafePointer, withError error: Error) -> Any? { + hasFailed = true let error = error as NSError FileHandle.standardError.write("Error from event: \(error.localizedDescription)") if let user = error.userInfo["ErrorString"] as? String { @@ -140,11 +168,13 @@ extension UTMCtl { case virtualMachineNotFound case invalidIdentifier(String) case deviceNotFound + case snapshotNotFound var errorDescription: String? { switch self { case .applicationNotFound: return "Application not found." case .virtualMachineNotFound: return "Virtual machine not found." + case .snapshotNotFound: return "Snapshot not found." case .invalidIdentifier(let identifier): return "Identifier '\(identifier)' is invalid." case .deviceNotFound: return "Device not found." } @@ -741,92 +771,198 @@ extension UTMCtl { extension UTMCtl { struct Snapshot: ParsableCommand { static var configuration = CommandConfiguration( - abstract: "Create, list, restore, and delete named full-VM snapshots for QEMU virtual machines.", - subcommands: [SnapshotCreate.self, SnapshotList.self, SnapshotRestore.self, SnapshotDelete.self] + abstract: "List and manage the saved states of a virtual machine.", + subcommands: [SnapshotList.self, SnapshotCreate.self, SnapshotRestore.self, SnapshotOverwrite.self, SnapshotRename.self, SnapshotDelete.self, SnapshotScan.self] ) } - struct SnapshotCreate: UTMAPICommand { - static var configuration = CommandConfiguration( - commandName: "create", - abstract: "Create or replace a named snapshot of a running or paused QEMU virtual machine." - ) - - @OptionGroup var environment: EnvironmentOptions - - @OptionGroup var identifer: VMIdentifier - - @Option(help: "Name of the snapshot to create.") - var name: String - - func run(with application: UTMScriptingApplication) throws { - let vm = try virtualMachine(forIdentifier: identifer, in: application) - vm.createSnapshotNamed!(name) - } + /// Addresses one snapshot of a virtual machine + struct SnapshotIdentifier: ParsableArguments { + @Option(name: .shortAndLong, help: "Either the name or the identifier of the snapshot.") + var snapshot: String } struct SnapshotList: UTMAPICommand { static var configuration = CommandConfiguration( commandName: "list", - abstract: "List the names of all snapshots for a stopped QEMU virtual machine." + abstract: "List the snapshots of a virtual machine, newest first." ) @OptionGroup var environment: EnvironmentOptions @OptionGroup var identifer: VMIdentifier - @Flag(help: "Output the list as a JSON array.") + @Flag(help: "Output the list as JSON.") var json: Bool = false + @Flag(help: "Look for snapshots made by other tools before listing.") + var scan: Bool = false + func run(with application: UTMScriptingApplication) throws { let vm = try virtualMachine(forIdentifier: identifer, in: application) - let names = (vm.listSnapshots!() as? [String]) ?? [] + if scan { + vm.scanSnapshots!() + } + let snapshots = snapshots(of: vm) if json { - let data = try JSONSerialization.data(withJSONObject: names, options: [.prettyPrinted]) + let rows = snapshots.map { snapshot -> [String: Any] in + ["id": snapshot.id!(), + "name": snapshot.name!, + "created": ISO8601DateFormatter().string(from: snapshot.creationDate!), + "modified": ISO8601DateFormatter().string(from: snapshot.modificationDate!), + "size": snapshot.size!, + "includesRunningState": snapshot.includesRunningState!, + "dataMissing": snapshot.dataMissing!, + "basedOn": snapshot.basedOn!] + } + let data = try JSONSerialization.data(withJSONObject: rows, options: [.prettyPrinted, .sortedKeys]) print(String(data: data, encoding: .utf8) ?? "[]") } else { - for name in names { - print(name) + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + for snapshot in snapshots { + var notes = [snapshot.includesRunningState! ? "resumes" : "disks only"] + if snapshot.dataMissing! { + notes.append("data missing") + } + let size = snapshot.size! > 0 ? ByteCountFormatter.string(fromByteCount: Int64(snapshot.size!), countStyle: .binary) : "-" + print("\(snapshot.name!)\t\(formatter.string(from: snapshot.creationDate!))\t\(size)\t\(notes.joined(separator: ", "))") } } } } + struct SnapshotCreate: UTMAPICommand { + static var configuration = CommandConfiguration( + commandName: "create", + abstract: "Save the current state of a virtual machine as a new snapshot." + ) + + @OptionGroup var environment: EnvironmentOptions + + @OptionGroup var identifer: VMIdentifier + + @Option(help: "Name for the new snapshot. A name already in use gets a number appended.") + var name: String? + + func run(with application: UTMScriptingApplication) throws { + let vm = try virtualMachine(forIdentifier: identifer, in: application) + let snapshot = vm.createSnapshotNamed!(name) + // nothing is returned when the event failed, which has been reported already + guard !EventErrorHandler.shared.hasFailed else { + throw ExitCode.failure + } + print(snapshot.name!) + } + } + struct SnapshotRestore: UTMAPICommand { static var configuration = CommandConfiguration( commandName: "restore", - abstract: "Restore a stopped QEMU virtual machine to a named snapshot." + abstract: "Replace the current state of a virtual machine with a snapshot." ) @OptionGroup var environment: EnvironmentOptions @OptionGroup var identifer: VMIdentifier - @Option(help: "Name of the snapshot to restore.") - var name: String + @OptionGroup var snapshotIdentifer: SnapshotIdentifier func run(with application: UTMScriptingApplication) throws { let vm = try virtualMachine(forIdentifier: identifer, in: application) - vm.restoreSnapshotNamed!(name) + try snapshot(forIdentifier: snapshotIdentifer, of: vm).restore!() + } + } + + struct SnapshotOverwrite: UTMAPICommand { + static var configuration = CommandConfiguration( + commandName: "overwrite", + abstract: "Replace what a snapshot holds with the current state of the virtual machine." + ) + + @OptionGroup var environment: EnvironmentOptions + + @OptionGroup var identifer: VMIdentifier + + @OptionGroup var snapshotIdentifer: SnapshotIdentifier + + func run(with application: UTMScriptingApplication) throws { + let vm = try virtualMachine(forIdentifier: identifer, in: application) + try snapshot(forIdentifier: snapshotIdentifer, of: vm).overwrite!() + } + } + + struct SnapshotRename: UTMAPICommand { + static var configuration = CommandConfiguration( + commandName: "rename", + abstract: "Change the name shown for a snapshot." + ) + + @OptionGroup var environment: EnvironmentOptions + + @OptionGroup var identifer: VMIdentifier + + @OptionGroup var snapshotIdentifer: SnapshotIdentifier + + @Option(help: "New name for the snapshot.") + var to: String + + func run(with application: UTMScriptingApplication) throws { + let vm = try virtualMachine(forIdentifier: identifer, in: application) + let snapshot = try snapshot(forIdentifier: snapshotIdentifer, of: vm) + snapshot.setName!(to) + print(snapshot.name!) } } struct SnapshotDelete: UTMAPICommand { static var configuration = CommandConfiguration( commandName: "delete", - abstract: "Delete a named snapshot from a QEMU virtual machine." + abstract: "Delete a snapshot of a virtual machine." ) @OptionGroup var environment: EnvironmentOptions @OptionGroup var identifer: VMIdentifier - @Option(help: "Name of the snapshot to delete.") - var name: String + @OptionGroup var snapshotIdentifer: SnapshotIdentifier func run(with application: UTMScriptingApplication) throws { let vm = try virtualMachine(forIdentifier: identifer, in: application) - vm.deleteSnapshotNamed!(name) + try snapshot(forIdentifier: snapshotIdentifer, of: vm).deleteSnapshot!() + } + } + + struct SnapshotScan: UTMAPICommand { + static var configuration = CommandConfiguration( + commandName: "scan", + abstract: "Look through the disk images for snapshots made by other tools and mark any whose data is missing." + ) + + @OptionGroup var environment: EnvironmentOptions + + @OptionGroup var identifer: VMIdentifier + + func run(with application: UTMScriptingApplication) throws { + let vm = try virtualMachine(forIdentifier: identifer, in: application) + vm.scanSnapshots!() + } + } + + struct DiscardState: UTMAPICommand { + static var configuration = CommandConfiguration( + commandName: "discard-state", + abstract: "Discard the saved state a suspended virtual machine would resume from, so that it starts up normally." + ) + + @OptionGroup var environment: EnvironmentOptions + + @OptionGroup var identifer: VMIdentifier + + func run(with application: UTMScriptingApplication) throws { + let vm = try virtualMachine(forIdentifier: identifer, in: application) + vm.discardSavedState!() } } }