From a69ad794dd496a11f830e4e8c3232a1d8c42e245 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:57:29 -0500 Subject: [PATCH 1/9] QEMULauncher: pass on the exit status of qemu-img qemu-img reports most failures, such as an image that cannot be opened, by returning from main rather than by exiting. The launcher dropped that value and always exited with 0, so on macOS a conversion, resize or snapshot operation that failed looked like it had succeeded. Snapshots of a VM that is off depend on those errors to know that a state was not deleted or reverted. Assisted-by: Claude:claude-opus-5 --- QEMULauncher/Bootstrap.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 { From cb9e95af4df1ae6628328069c1be1ab1dd09d10c Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:39:37 -0500 Subject: [PATCH 2/9] snapshots: add snapshot management to the VM details view Snapshots could only be managed through scripting. Split the details view into tabs (a tab view on macOS and a segmented control elsewhere, following the HIG) and add a Snapshots tab next to the existing details. The tab lists the current state followed by every snapshot, newest first, along a timeline. A snapshot never changes unless it is overwritten, and restoring one replaces the current state with a copy, so the actions say as much: the current state can be saved as a new snapshot or overwrite the one it is based on, and a snapshot can be restored or deleted. Anything that loses data asks first. A snapshot is based on whatever the current state was based on when it was created. One that follows what it is based on is joined to it so a lineage without branches reads as a single line. A base that is elsewhere in the list is named by a link instead, which keeps branches of any depth from widening the list. The lineage of the focused row is highlighted and only that row shows its actions. Rows choose their layout from the measured width because a regular size class can still be narrow next to the sidebar. The operations only modify an in-memory list for now and no snapshots are read from the VM yet. Whether a snapshot holds the running machine decides whether restoring it resumes the guest or starts it up, so the mark on the timeline is filled for one that does and hollow for one that holds only the disks. Assisted-by: Claude:claude-fable-5-1 --- Platform/Shared/VMDetailsView.swift | 163 +++-- Platform/Shared/VMSnapshotsView.swift | 886 ++++++++++++++++++++++++++ Platform/VMSnapshotData.swift | 209 ++++++ UTM.xcodeproj/project.pbxproj | 16 + 4 files changed, 1230 insertions(+), 44 deletions(-) create mode 100644 Platform/Shared/VMSnapshotsView.swift create mode 100644 Platform/VMSnapshotData.swift diff --git a/Platform/Shared/VMDetailsView.swift b/Platform/Shared/VMDetailsView.swift index 61f6360b..c41c7443 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,24 @@ 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]) + tabs(scrollProxy: scrollProxy) #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 +111,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/VMSnapshotData.swift b/Platform/VMSnapshotData.swift new file mode 100644 index 00000000..595a7b66 --- /dev/null +++ b/Platform/VMSnapshotData.swift @@ -0,0 +1,209 @@ +// +// 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. +/// +/// FIXME: all operations are placeholders that only modify the in-memory list. +@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 = true + + /// The snapshot the current state is based on can be replaced while the VM is in its current state + var canOverwriteBase: Bool { + canCreate && snapshot(for: currentParentID)?.canDelete == true + } + + private let vm: VMData + + private static var cache: [UUID: VMSnapshotList] = [:] + + /// Returns the list for a VM, keeping it alive so placeholder edits survive navigation. + static func list(for vm: VMData) -> VMSnapshotList { + 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 + } + + // 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 + + /// FIXME: read the snapshots from the VM + func refresh() async throws { + } + + func rename(_ snapshot: VMSnapshot, to title: String) async throws { + let title = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty, let index = snapshots.firstIndex(where: { $0.id == snapshot.id }) else { + return + } + snapshots[index].title = title + snapshots[index].dateModified = Date() + } + + /// 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 { + logger.debug("FIXME: saving the current state as a snapshot is not implemented") + let now = Date() + let snapshot = VMSnapshot(id: UUID(), + title: defaultTitle, + parentID: currentParentID, + dateCreated: now, + dateModified: now, + size: 0, + screenshot: vm.screenshotImage, + hasState: vm.hasSuspendState, + isOrphaned: false) + snapshots.insert(snapshot, at: 0) + currentParentID = snapshot.id + return snapshot.id + } + + /// Replaces what the snapshot the current state is based on holds with the current state. + func overwriteBase() async throws { + logger.debug("FIXME: overwriting a snapshot is not implemented") + guard let index = snapshots.firstIndex(where: { $0.id == currentParentID }) else { + return + } + snapshots[index].dateModified = Date() + snapshots[index].screenshot = vm.screenshotImage + snapshots[index].hasState = vm.hasSuspendState + } + + /// Snapshots based on a deleted snapshot are handed to what it was based on so the remaining lineage stays linked. + func delete(_ snapshot: VMSnapshot) async throws { + logger.debug("FIXME: deleting a snapshot is not implemented") + for index in snapshots.indices where snapshots[index].parentID == snapshot.id { + snapshots[index].parentID = snapshot.parentID + } + if currentParentID == snapshot.id { + currentParentID = snapshot.parentID + } + snapshots.removeAll { $0.id == snapshot.id } + } + + /// Replaces the current state with `snapshot`. If the VM is not running, it resumes from it when started. + func restore(_ snapshot: VMSnapshot) async throws { + logger.debug("FIXME: restoring a snapshot is not implemented") + currentParentID = snapshot.id + } + + /// Discards the state the VM is suspended to so that it starts up normally. + func deleteSuspendState() async throws { + logger.debug("FIXME: deleting the suspended state is not implemented") + } + + private var defaultTitle: String { + var number = snapshots.count + 1 + var title: String + repeat { + title = String.localizedStringWithFormat(NSLocalizedString("Snapshot %lld", comment: "VMSnapshotData"), number) + number += 1 + } while snapshots.contains { $0.title == title } + return title + } +} diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 57bac1b4..4cd683b2 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -168,8 +168,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 +618,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 */; }; @@ -752,6 +757,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 */; }; @@ -1796,6 +1802,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 +1994,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 = ""; }; @@ -2786,6 +2794,7 @@ 835AA7B026AB7C85007A0411 /* UTMPendingVirtualMachine.swift */, CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */, 847BF9A92A49C783000BD9AA /* VMData.swift */, + 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */, CE550BD52259479D0063E575 /* Assets.xcassets */, 521F3EFB2414F73800130500 /* Localizable.strings */, CED8DF7928A120C100C34345 /* Localizable.stringsdict */, @@ -3143,6 +3152,7 @@ 8432328F28C2CDAD00CFBC97 /* VMNavigationListView.swift */, CE2D954224AD4F980059923A /* VMPlaceholderView.swift */, CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */, + B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */, 84C4D9012880CA8A00EC3B2B /* VMSettingsAddDeviceMenuView.swift */, CE8813D424CD265700532628 /* VMShareFileModifier.swift */, CE2D953824AD4F980059923A /* VMToolbarModifier.swift */, @@ -3807,10 +3817,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 */, @@ -4135,6 +4147,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 */, @@ -4182,6 +4195,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 +4221,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 */, @@ -4345,6 +4360,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 */, From a021240ab38faff6c4e7cc0a51f9ca9caf8eb020 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:13:18 -0500 Subject: [PATCH 3/9] qemu-img: add snapshot subcommands Wrap `qemu-img snapshot` so the internal snapshots of an image can be listed, created, applied and deleted while QEMU is not running. Assisted-by: Claude:claude-fable-5-1 --- Services/UTMQemuImage.swift | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) 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 } From 2d48f084e60f3a7ddd64f0eeb88eb9880e8f01ba Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:13:39 -0500 Subject: [PATCH 4/9] apple(disk): stack disk image snapshots as layers DiskImageKit can put a copy-on-write layer on an image and remembers what each layer sits on. Use that to freeze the contents of a drive: the layer holding what the guest has written so far is closed off under the name of a snapshot and a fresh one is opened above it for further writes. Layers are kept beside the image as `..asif`. A layer whose snapshot is deleted cannot be merged away while other layers are stacked on it, so it is only marked as unlinked and is swept once nothing needs it. Attachments are built through the same path so that a drive with layers is opened as the whole stack, and a disposable run adds its throwaway layer on top of it. A stack is only ever opened whole. When one of its layers cannot be read the drive is not attached at all: opening the rest without it would hide what it holds, and falling back to the bare image would write underneath every layer stacked on it. For the same reason a layer to write to is always put in place before the one it replaces goes, and the image under a stack cannot be resized. Nor can a drive with layers be attached read only, which a stack cannot be, or before macOS 27, which cannot read the layers: the image alone is the drive as it was before the first snapshot. Removable media never has layers. Assisted-by: Claude:claude-fable-5-1 Assisted-by: Claude:claude-opus-5 --- .../UTMAppleConfigurationDrive.swift | 13 +- Services/UTMAppleDiskImage.swift | 264 +++++++++++++++++- 2 files changed, 264 insertions(+), 13 deletions(-) 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/Services/UTMAppleDiskImage.swift b/Services/UTMAppleDiskImage.swift index 1607628e..2a53b40e 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,251 @@ 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) + } + + /// 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") + } } } From 9e8bd13e3246fbbdbb8d166db61bd4d5a33277a2 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:13:39 -0500 Subject: [PATCH 5/9] snapshots: record snapshot metadata in the VM bundle A snapshot has a title, a date, a size, a screenshot and the snapshot it was based on. None of that fits in the image formats the backends use, so keep it in a new `snapshots.plist` in the VM bundle where each entry links to the state in the backend by identifier. The file also remembers which snapshot the current state came from and which state the VM resumes from while it is suspended, along with the states that are no longer needed but could not be deleted yet. Assisted-by: Claude:claude-fable-5-1 Assisted-by: Claude:claude-opus-5 --- Configuration/UTMSnapshotManifest.swift | 343 ++++++++++++++++++++++++ UTM.xcodeproj/project.pbxproj | 8 + 2 files changed, 351 insertions(+) create mode 100644 Configuration/UTMSnapshotManifest.swift 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/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 4cd683b2..2b8deb25 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -140,10 +140,13 @@ 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; @@ -1789,6 +1792,7 @@ 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 = ""; }; + 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 = ""; }; @@ -2910,6 +2914,7 @@ 848A98B5286A142C006F0550 /* UTMAppleConfigurationSerial.swift */, 848A98B3286A1215006F0550 /* UTMAppleConfigurationVirtualization.swift */, 841619A5284315C1000034B2 /* UTMQemuConfiguration.swift */, + 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */, 848D99C328670F650055C215 /* UTMQemuConfiguration+Arguments.swift */, 843BF82328441EAD0029D60D /* UTMQemuConfigurationDisplay.swift */, 8443EFF12845641600B2E6E2 /* UTMQemuConfigurationDrive.swift */, @@ -3911,6 +3916,7 @@ 848D99A8285DB5550055C215 /* VMConfigConstantPicker.swift in Sources */, 8453DCB4278CE5410037A0DA /* UTMQemuImage.swift in Sources */, 393A92DAE84A47029BA5F829 /* UTMSnapshotService.swift in Sources */, + AFC3FBE9C92083C52D800437 /* UTMSnapshotManifest.swift in Sources */, CE2D955924AD4F980059923A /* VMToolbarModifier.swift in Sources */, CE2D92DA24AD46670059923A /* VMCursor.m in Sources */, CE9375A124BBDDD10074066F /* VMConfigDriveDetailsView.swift in Sources */, @@ -4158,6 +4164,7 @@ CE2D956224AD4F990059923A /* VMSettingsView.swift in Sources */, 8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */, FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */, + E1B70310A596175A0E9F3025 /* UTMSnapshotManifest.swift in Sources */, CEDF83FA258AE24E0030E4AC /* UTMPasteboard.swift in Sources */, 848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */, 848A98BA286A17A8006F0550 /* UTMAppleConfigurationNetwork.swift in Sources */, @@ -4302,6 +4309,7 @@ 848A98C5286F332D006F0550 /* UTMConfiguration.swift in Sources */, 8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */, 41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */, + BCB19D49AC1352354193ACB6 /* UTMSnapshotManifest.swift in Sources */, 84C60FBB268269D700B58C00 /* VMDisplayViewController.swift in Sources */, 8443EFFB28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */, 848D99BD28636AC90055C215 /* UTMConfigurationDrive.swift in Sources */, From 0086417b97632d2cd8973d653f50550f38197f87 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:14:07 -0500 Subject: [PATCH 6/9] snapshots: manage QEMU snapshots through a backend Snapshots were read straight out of the QEMU images by the scripting commands, which only worked while the VM was off and knew nothing beyond the tags in the image. Put a backend behind the operations instead. Which one is chosen depends on whether the VM is running, and each says what it can do in that state: - A running VM goes through the QEMU monitor, which saves and loads the whole machine along with the disks. - A VM that is off goes through qemu-img, which can only save and revert disk contents. Nothing may touch the images while QEMU has them open, so a snapshot of disks alone is left alone until the VM stops. Either way a snapshot spans every writable image, the UEFI variable store included, because QEMU refuses to load one that any of them is missing. A snapshot that some of them lack, such as one taken before a drive was added, is kept but cannot be restored, which is not the same as its state being gone: reverting only some of the drives would leave them at different points in time. Reclaiming space rebuilds an image without its snapshots, so it is refused while there are any. UTMSnapshotService sits above the backend and keeps the manifest and the stored state consistent: it marks entries whose state has disappeared when an operation fails, and the first time a VM is opened it adopts whatever snapshots are already in the images so that snapshots taken by other tools are not lost. Both wait until the VM is stopped, because a running QEMU lists the snapshots of every drive together, removable ones included. Suspending is folded into the same mechanism. A VM now suspends to a snapshot of its own rather than always the same name, so a suspended state can be kept as a snapshot without copying it, and restoring a snapshot makes the VM resume from it. The state is deleted once nothing refers to it, where it used to be left behind in the image. One that cannot be deleted right away stays listed in the manifest and is tried again the next time. Assisted-by: Claude:claude-fable-5-1 Assisted-by: Claude:claude-opus-5 --- .../UTMQemuConfiguration+Arguments.swift | 2 +- Platform/UTMData.swift | 7 + .../UTMScriptingVirtualMachineImpl.swift | 3 +- Services/UTMQemuSnapshotBackend.swift | 210 +++++++ Services/UTMQemuVirtualMachine.swift | 119 ++-- Services/UTMSnapshotBackend.swift | 85 +++ Services/UTMSnapshotService.swift | 594 +++++++++++++++--- UTM.xcodeproj/project.pbxproj | 16 + .../xcshareddata/swiftpm/Package.resolved | 2 +- 9 files changed, 906 insertions(+), 132 deletions(-) create mode 100644 Services/UTMQemuSnapshotBackend.swift create mode 100644 Services/UTMSnapshotBackend.swift 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/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/Scripting/UTMScriptingVirtualMachineImpl.swift b/Scripting/UTMScriptingVirtualMachineImpl.swift index a0e12634..ac2a2270 100644 --- a/Scripting/UTMScriptingVirtualMachineImpl.swift +++ b/Scripting/UTMScriptingVirtualMachineImpl.swift @@ -299,8 +299,7 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable { @objc func listSnapshots(_ command: NSScriptCommand) { withScriptCommand(command) { [self] in - let entries = try await UTMSnapshotService.listSnapshots(on: vm) - return entries.map { $0.name } + return try await UTMSnapshotService.listSnapshots(on: vm) } } 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..bd0b5279 100644 --- a/Services/UTMSnapshotService.swift +++ b/Services/UTMSnapshotService.swift @@ -15,138 +15,554 @@ // 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 + } + #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)) + } + + /// An operation is possible while the VM is in its current state. /// - /// QEMU replaces an existing snapshot with the same name. + /// 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 + } + if operation == .create && backend.imageURLs.isEmpty { + return false + } + return backend.supports(operation, hasState: snapshot?.hasState ?? true) + } + + /// 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 + } + 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) + } + let snapshot = Entry(title: title ?? 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) + } + 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) + } + 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 + } + snapshot.title = title + 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 + } + } + 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)) + } + } + + /// 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 backend.supports(.restore, hasState: snapshot.hasState) else { + throw UTMSnapshotError.requiresStoppedVm + } + 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) + } + + // 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 + } + 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: - Snapshots by name + + /// Create a snapshot or overwrite the one with the same title. 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 + if let snapshot = try await timeline(for: vm).snapshots.first(where: { $0.title == name }) { + try await overwriteSnapshot(snapshot.id, on: vm) + } else { + try await createSnapshot(title: name, on: vm) } - // 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 - } - snapshotLogger.debug("Creating snapshot '\(name)' on QEMU VM") - try await vm.saveSnapshot(name: name) } - /// 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 - } - 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 - } - return drive.imageURL - } - if qemu.config.qemu.hasUefiBoot, - let efiVarsURL = qemu.config.qemu.efiVarsURL, - FileManager.default.fileExists(atPath: efiVarsURL.path) { - imageURLs.insert(efiVarsURL, at: 0) - } - 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 - } - } - return snapshotsByName.values.sorted { $0.date > $1.date } + /// Titles of all snapshots, newest first + static func listSnapshots(on vm: any UTMVirtualMachine) async throws -> [String] { + _ = try backend(for: vm) + return try await timeline(for: vm).snapshots.map { $0.title } } - /// 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 - } - guard try await listSnapshots(on: vm).contains(where: { $0.name == name }) else { + try await restoreSnapshot(try await snapshot(named: name, on: vm).id, on: vm) + } + + static func deleteSnapshot(name: String, on vm: any UTMVirtualMachine) async throws { + try await deleteSnapshot(try await snapshot(named: name, on: vm).id, on: vm) + } + + private static func snapshot(named name: String, on vm: any UTMVirtualMachine) async throws -> UTMSnapshot { + guard let snapshot = try await timeline(for: vm).snapshots.first(where: { $0.title == name }) else { throw UTMSnapshotError.notFound(name) } - snapshotLogger.debug("Restoring snapshot '\(name)' on QEMU VM") - try await vm.restoreSnapshot(name: name) - } - - /// 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 - } - snapshotLogger.debug("Deleting snapshot '\(name)' on QEMU VM") - try await vm.deleteSnapshot(name: name) + return snapshot } // 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 + } + } + 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 + } + + func uniqueTitle(_ base: String) -> String { + var title = base + var number = 2 + while snapshots.contains(where: { $0.title == title }) { + 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 2b8deb25..087dd8c7 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -140,12 +140,18 @@ 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 */; }; @@ -1792,6 +1798,8 @@ 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 = ""; }; @@ -2997,6 +3005,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 */, @@ -3916,6 +3926,8 @@ 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 */, @@ -4164,6 +4176,8 @@ 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 */, @@ -4309,6 +4323,8 @@ 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 */, 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" } }, { From f4a3c5ac7c72b9609ac2afe6843567df55437212 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:14:38 -0500 Subject: [PATCH 7/9] snapshots: manage Apple VM snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple virtualization reported snapshots as unsupported. It has no notion of them, but the pieces are there: the disks can be layered and the saved state and firmware storage are ordinary files. Add a backend that freezes a layer per drive and keeps copies of the saved state, the EFI variables and the macOS auxiliary storage beside them. The layers under a disk that is attached cannot be changed, so a running VM may only delete; everything else waits until it stops. A layer sits beside the image it belongs to and is named after it, and the saved state and firmware of a snapshot are copies named the same way. A bundle only keeps the files its configuration names, so saving the configuration would take all of them with it. Work out what the snapshots still need instead — the layer being written to, the layers of the snapshots in the bundle, and whatever lies underneath those — and keep exactly that, so anything left over is still cleaned up. When that cannot be worked out, because the manifest or a layer cannot be read, everything named after the file is kept instead. Assisted-by: Claude:claude-fable-5-1 Assisted-by: Claude:claude-opus-5 --- Configuration/UTMAppleConfiguration.swift | 3 + Services/UTMAppleDiskImage.swift | 27 +++ Services/UTMAppleSnapshotBackend.swift | 256 ++++++++++++++++++++++ Services/UTMAppleVirtualMachine.swift | 43 +++- Services/UTMSnapshotService.swift | 14 ++ UTM.xcodeproj/project.pbxproj | 4 + 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 Services/UTMAppleSnapshotBackend.swift diff --git a/Configuration/UTMAppleConfiguration.swift b/Configuration/UTMAppleConfiguration.swift index 81a275bc..30903844 100644 --- a/Configuration/UTMAppleConfiguration.swift +++ b/Configuration/UTMAppleConfiguration.swift @@ -384,6 +384,9 @@ private extension String { existingDataURLs += try await _drives[i].saveData(to: dataURL) } + // what the snapshots hold sits beside the files above and is named after them + existingDataURLs += UTMAppleSnapshotFiles.preservedDataURLs(for: existingDataURLs, in: dataURL.deletingLastPathComponent()) + return existingDataURLs } } diff --git a/Services/UTMAppleDiskImage.swift b/Services/UTMAppleDiskImage.swift index 2a53b40e..7e17a436 100644 --- a/Services/UTMAppleDiskImage.swift +++ b/Services/UTMAppleDiskImage.swift @@ -337,6 +337,33 @@ extension UTMAppleDiskImage { try removeUnusedLayers(of: imageURL) } + /// Layers of an image that are still needed, given the snapshots that are still wanted. + /// + /// A layer is needed when it is the one being written to, when it belongs to one of `names`, + /// or when it lies under a layer that is needed. Anything else is left over and may go. + /// - Parameters: + /// - imageURL: Location of the image + /// - names: Names of the snapshots to keep + static func preservedLayerURLs(of imageURL: URL, for names: Set) throws -> [URL] { + let fileURLs = layerFileURLs(of: imageURL) + let layers = try layers(of: imageURL) + // a layer that could not be read, such as the one a running VM writes to, may be needed + // by any of the others and is kept along with all of them + guard layers.count == fileURLs.count else { + return fileURLs + } + var preserved = Set() + for top in layers where top.isCurrent || names.contains(top.name) { + guard let chain = try? chain(endingAt: top, in: layers) else { + return fileURLs + } + for layer in chain { + preserved.insert(layer.url) + } + } + return Array(preserved) + } + /// Delete the layers of deleted snapshots that no other layer needs anymore private static func removeUnusedLayers(of imageURL: URL) throws { var layers = try layers(of: imageURL) diff --git a/Services/UTMAppleSnapshotBackend.swift b/Services/UTMAppleSnapshotBackend.swift new file mode 100644 index 00000000..971596c9 --- /dev/null +++ b/Services/UTMAppleSnapshotBackend.swift @@ -0,0 +1,256 @@ +// +// Copyright © 2026 Turing Software, LLC. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +extension UTMAppleVirtualMachine { + /// Images that take part in a snapshot + @MainActor var snapshotImageURLs: [URL] { + config.drives.compactMap { drive in + drive.isReadOnly || drive.isExternal ? nil : drive.imageURL + } + } + + /// Small files the guest writes to that are not disks, such as the variables of the firmware + @MainActor var snapshotAuxiliaryURLs: [URL] { + [config.system.boot.efiVariableStorageURL, config.system.macPlatform?.auxiliaryStorageURL].compactMap { url in + url.flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil } + } + } +} + +/// Which files a bundle has to keep for the snapshots of an Apple VM. +/// +/// A bundle only keeps the files its configuration names, and what a snapshot holds is named +/// after the file it belongs to rather than being named by the configuration. +enum UTMAppleSnapshotFiles { + /// Files beside `baseURLs` that the snapshots recorded in the bundle still need. + /// + /// Everything else beside them is left over — from a snapshot that was deleted, or from a + /// tool that is no longer around — and is left out so that it is cleaned up with the rest. + /// - Parameters: + /// - baseURLs: Files the configuration keeps + /// - bundleURL: Bundle of the VM + static func preservedDataURLs(for baseURLs: [URL], in bundleURL: URL) -> [URL] { + // without the manifest nothing can be ruled out, and snapshots it does not list yet are + // adopted from these files the next time the snapshots are read + guard let manifest = (try? UTMSnapshotManifest.load(from: bundleURL)) ?? nil else { + return baseURLs.flatMap { namedAfter($0) } + } + var identifiers = Set(manifest.snapshots.map { $0.backendIdentifier }) + if let suspendIdentifier = manifest.suspendIdentifier { + identifiers.insert(suspendIdentifier) + } + let fileManager = FileManager.default + var preserved = [URL]() + for baseURL in baseURLs { + // the saved state and the firmware of a snapshot are copies named after the original + for identifier in identifiers { + let url = baseURL.appendingPathExtension(identifier) + if fileManager.fileExists(atPath: url.path) { + preserved.append(url) + } + } + if #available(macOS 27, *), let layerURLs = try? UTMAppleDiskImage.preservedLayerURLs(of: baseURL, for: identifiers) { + preserved += layerURLs + } else { + // the layers cannot be read, so keep all of them rather than lose one + preserved += namedAfter(baseURL).filter { $0.pathExtension == "asif" } + } + } + return preserved + } + + /// Files beside `baseURL` whose names start with its own, which is how snapshots name theirs + private static func namedAfter(_ baseURL: URL) -> [URL] { + let prefix = baseURL.lastPathComponent + "." + let siblings = (try? FileManager.default.contentsOfDirectory(at: baseURL.deletingLastPathComponent(), includingPropertiesForKeys: nil)) ?? [] + return siblings.filter { $0.lastPathComponent.hasPrefix(prefix) } + } +} + +/// Snapshots of a VM using Apple Virtualization. +/// +/// A snapshot spans every drive. The disks are kept as a layer of each image (see +/// `UTMAppleDiskImage`), while the running state and the files in `auxiliaryURLs` are small +/// enough to be kept as copies named after the snapshot. Virtualization cannot change the layers of a +/// disk that is attached, so the current state can only be saved or replaced while the VM is +/// not running. Everything else works either way. +@available(macOS 27, *) +struct UTMAppleSnapshotBackend: UTMSnapshotBackend { + let imageURLs: [URL] + + let isInventoryExact = true + + /// Files that are not disks but still part of the state of the guest + let auxiliaryURLs: [URL] + + /// File the VM is suspended to + let savedStateURL: URL? + + let isRunning: Bool + + /// Kept from starting while its files change + var vm: UTMAppleVirtualMachine? = nil + + /// There is only one file to suspend to which is never a snapshot by itself + let defaultSuspendIdentifier = "suspend" + + func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool { + // the layers below a disk that is attached have to stay the same + !isRunning || operation == .delete + } + + private var fileManager: FileManager { + FileManager.default + } + + private func snapshotStateURL(for identifier: String) -> URL? { + savedStateURL?.appendingPathExtension(identifier) + } + + /// Copy beside `url` for `replaceItemAt`, which consumes what it puts in place + private func copyToTemporary(_ url: URL) throws -> URL { + let temporaryURL = url.appendingPathExtension(UUID().uuidString) + try fileManager.copyItem(at: url, to: temporaryURL) + return temporaryURL + } + + private func size(of url: URL) -> Int64 { + let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]) + return Int64(values?.totalFileAllocatedSize ?? 0) + } + + func entries() async throws -> [UTMSnapshotBackendEntry] { + var entries = [String: UTMSnapshotBackendEntry]() + var imageCounts = [String: Int]() + for imageURL in imageURLs { + let layers = try UTMAppleDiskImage.layers(of: imageURL) + for layer in layers where !layer.isCurrent && !layer.isUnlinked { + // skip the layers of deleted snapshots to find the one this was taken from + var parent = layers.first { $0.uuid == layer.parentUUID } + while let unlinked = parent, unlinked.isUnlinked { + parent = layers.first { $0.uuid == unlinked.parentUUID } + } + let date = (try? layer.url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? Date() + let previous = entries[layer.name] + entries[layer.name] = UTMSnapshotBackendEntry(identifier: layer.name, + date: max(date, previous?.date ?? .distantPast), + size: size(of: layer.url) + (previous?.size ?? 0), + hasState: false, + parentIdentifier: previous?.parentIdentifier ?? parent?.name) + imageCounts[layer.name, default: 0] += 1 + } + } + return entries.values.map { entry in + var entry = entry + entry.isComplete = imageCounts[entry.identifier] == imageURLs.count + guard let stateURL = snapshotStateURL(for: entry.identifier), fileManager.fileExists(atPath: stateURL.path) else { + return entry + } + return UTMSnapshotBackendEntry(identifier: entry.identifier, + date: entry.date, + size: entry.size + size(of: stateURL), + hasState: true, + isComplete: entry.isComplete, + parentIdentifier: entry.parentIdentifier) + } + } + + func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry { + let identifier = UUID().uuidString + try await changingFiles(as: .saving) { + do { + for imageURL in imageURLs { + try UTMAppleDiskImage.createSnapshot(of: imageURL, name: identifier) + } + for auxiliaryURL in auxiliaryURLs { + try fileManager.copyItem(at: auxiliaryURL, to: auxiliaryURL.appendingPathExtension(identifier)) + } + if suspendIdentifier != nil, let savedStateURL = savedStateURL, let stateURL = snapshotStateURL(for: identifier) { + // the VM still resumes from its own copy + try fileManager.copyItem(at: savedStateURL, to: stateURL) + } + } catch { + try? await delete(identifier: identifier) + throw error + } + } + guard let entry = try await entries().first(where: { $0.identifier == identifier }) else { + throw UTMSnapshotError.notFound(identifier) + } + return entry + } + + func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool { + guard entry.isComplete else { + throw UTMSnapshotError.incomplete(entry.identifier) + } + try await changingFiles(as: .restoring) { + // every drive is restored or none is + for imageURL in imageURLs { + try UTMAppleDiskImage.validateSnapshot(of: imageURL, name: entry.identifier) + } + for imageURL in imageURLs { + try UTMAppleDiskImage.restoreSnapshot(of: imageURL, name: entry.identifier) + // what the VM was suspended to stops matching the drives once one is restored + if let savedStateURL = savedStateURL, fileManager.fileExists(atPath: savedStateURL.path) { + try? fileManager.removeItem(at: savedStateURL) + } + } + for auxiliaryURL in auxiliaryURLs { + let snapshotURL = auxiliaryURL.appendingPathExtension(entry.identifier) + if fileManager.fileExists(atPath: snapshotURL.path) { + // the original stays until the copy is in place so a failure cannot lose it + _ = try fileManager.replaceItemAt(auxiliaryURL, withItemAt: try copyToTemporary(snapshotURL)) + } + } + if let savedStateURL = savedStateURL, entry.hasState, let stateURL = snapshotStateURL(for: entry.identifier) { + try fileManager.copyItem(at: stateURL, to: savedStateURL) + } + } + return savedStateURL != nil && entry.hasState + } + + private func changingFiles(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws { + if let vm = vm { + try await vm.changingFiles(as: state, body) + } else { + try await body() + } + } + + func delete(identifier: String) async throws { + var isFound = false + for imageURL in imageURLs { + do { + try UTMAppleDiskImage.deleteSnapshot(of: imageURL, name: identifier) + isFound = true + } catch UTMAppleDiskImageError.snapshotNotFound(_) { + // a drive added after the snapshot was created + } + } + for auxiliaryURL in auxiliaryURLs { + try? fileManager.removeItem(at: auxiliaryURL.appendingPathExtension(identifier)) + } + if let stateURL = snapshotStateURL(for: identifier) { + try? fileManager.removeItem(at: stateURL) + } + guard isFound else { + throw UTMSnapshotError.notFound(identifier) + } + } +} diff --git a/Services/UTMAppleVirtualMachine.swift b/Services/UTMAppleVirtualMachine.swift index 5cb37d05..18586a3e 100644 --- a/Services/UTMAppleVirtualMachine.swift +++ b/Services/UTMAppleVirtualMachine.swift @@ -287,6 +287,8 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { state = .stopped if !isDisposible { try? await deleteSnapshot() + // the layers of a snapshot below the disks may be gone + try? await UTMSnapshotService.identifyOrphanedSnapshots(on: self) } throw error } @@ -553,9 +555,48 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine { } await registryEntry.setIsSuspended(false) await registryEntry.setConnectedUsbDevices([]) - try FileManager.default.removeItem(at: vmSavedStateURL) + // a state that is gone already is still let go of below + if FileManager.default.fileExists(atPath: vmSavedStateURL.path) { + try FileManager.default.removeItem(at: vmSavedStateURL) + } + if #available(macOS 27, *) { + try? await releaseSuspendSnapshot(savedStateURL: vmSavedStateURL) + } try? updateLastModified() } + + /// Keep the VM from starting while the files of its snapshots change, which it would otherwise + /// attach halfway through + @available(macOS 27, *) + func changingFiles(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws { + guard self.state == .stopped else { + throw UTMAppleVirtualMachineError.operationNotAvailable + } + self.state = state + defer { + self.state = .stopped + } + try await body() + } + + /// The saved state may have been a copy of a snapshot that was only kept to resume from + @available(macOS 27, *) + private func releaseSuspendSnapshot(savedStateURL: URL) async throws { + let staleIdentifiers = try UTMSnapshotManifest.releaseSuspendState(in: pathUrl, default: nil) + guard !staleIdentifiers.isEmpty else { + return + } + let backend = UTMAppleSnapshotBackend(imageURLs: await snapshotImageURLs, auxiliaryURLs: await snapshotAuxiliaryURLs, savedStateURL: savedStateURL, isRunning: state != .stopped) + // one that cannot go now stays listed and goes the next time + for identifier in staleIdentifiers { + do { + try await backend.delete(identifier: identifier) + } catch UTMSnapshotError.notFound(_) { + // already gone + } + try UTMSnapshotManifest.forgetState(identifier, in: pathUrl) + } + } #if arch(arm64) @available(macOS 14, *) diff --git a/Services/UTMSnapshotService.swift b/Services/UTMSnapshotService.swift index bd0b5279..66d42579 100644 --- a/Services/UTMSnapshotService.swift +++ b/Services/UTMSnapshotService.swift @@ -43,6 +43,8 @@ enum UTMSnapshotService { #if os(macOS) if vm is UTMQemuVirtualMachine { return true + } else if #available(macOS 27, *), vm is UTMAppleVirtualMachine { + return true } #endif // qemu-img is needed for a VM that is not running and is only part of the macOS app @@ -379,6 +381,18 @@ enum UTMSnapshotService { default: throw UTMSnapshotError.invalidVmState } } + #if os(macOS) + if #available(macOS 27, *), let apple = vm as? UTMAppleVirtualMachine { + guard [.started, .paused, .stopped].contains(vm.state) else { + throw UTMSnapshotError.invalidVmState + } + return UTMAppleSnapshotBackend(imageURLs: apple.snapshotImageURLs, + auxiliaryURLs: apple.snapshotAuxiliaryURLs, + savedStateURL: apple.config.system.boot.vmSavedStateURL, + isRunning: vm.state != .stopped, + vm: apple) + } + #endif throw UTMSnapshotError.notSupported } diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 087dd8c7..9bda9502 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -687,6 +687,7 @@ CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */; }; CE7A1C062EFA0A01001671E9 /* AccessoryAccess.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */; }; + 1C5910D866A20395225768B9 /* UTMAppleSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */; }; CE1D15AD2F6B100100D15C01 /* DiskImageKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; CE6804852E4E5D84001671E9 /* UTMScriptingInputImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */; }; CE68E5442E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; }; @@ -2060,6 +2061,7 @@ CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleUSBManager.swift; sourceTree = ""; }; CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AccessoryAccess.framework; path = System/Library/Frameworks/AccessoryAccess.framework; sourceTree = SDKROOT; }; CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleDiskImage.swift; sourceTree = ""; }; + 0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleSnapshotBackend.swift; sourceTree = ""; }; CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DiskImageKit.framework; path = System/Library/Frameworks/DiskImageKit.framework; sourceTree = SDKROOT; }; CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingInputImpl.swift; sourceTree = ""; }; CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMKeyboardShortcutsView.swift; sourceTree = ""; }; @@ -3023,6 +3025,7 @@ CE7A1C012EFA0A01001671E9 /* UTMAppleUSBDevice.swift */, CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */, CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */, + 0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */, CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */, CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */, 4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */, @@ -4207,6 +4210,7 @@ CE7A1C022EFA0A01001671E9 /* UTMAppleUSBDevice.swift in Sources */, CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */, CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */, + 1C5910D866A20395225768B9 /* UTMAppleSnapshotBackend.swift in Sources */, CEF0306F26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */, 83034C0926AB630F006B4BAF /* UTMPendingVMView.swift in Sources */, 84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */, From e79d3b3bf03004258e6667599c3a43327e7197d8 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:14:38 -0500 Subject: [PATCH 8/9] snapshots: fill in the snapshots tab The tab was built against a list that only held its entries in memory. Read them from the VM instead, and send every action to UTMSnapshotService. Which actions a snapshot offers now follows what the backend can do in the state the VM is in, so a snapshot that cannot be restored right now says so by being unavailable rather than by failing afterwards. qemu-img is only part of the macOS app, so a VM whose snapshots cannot be managed at all shows the details on their own as before. Assisted-by: Claude:claude-fable-5-1 Assisted-by: Claude:claude-opus-5 --- Platform/Shared/VMDetailsView.swift | 6 +- Platform/VMSnapshotData.swift | 139 +++++++++++++++++----------- 2 files changed, 89 insertions(+), 56 deletions(-) diff --git a/Platform/Shared/VMDetailsView.swift b/Platform/Shared/VMDetailsView.swift index c41c7443..441aec44 100644 --- a/Platform/Shared/VMDetailsView.swift +++ b/Platform/Shared/VMDetailsView.swift @@ -70,7 +70,11 @@ struct VMDetailsView: View { #if WITH_REMOTE // FIXME: implement remote feature detailsPane #else - tabs(scrollProxy: scrollProxy) + if let wrapped = vm.wrapped, UTMSnapshotService.isSupported(for: wrapped) { + tabs(scrollProxy: scrollProxy) + } else { + detailsPane + } #endif } #if !WITH_REMOTE diff --git a/Platform/VMSnapshotData.swift b/Platform/VMSnapshotData.swift index 595a7b66..f15e8f59 100644 --- a/Platform/VMSnapshotData.swift +++ b/Platform/VMSnapshotData.swift @@ -69,8 +69,6 @@ struct VMSnapshot: Identifiable, Equatable { } /// Snapshots of a single VM along with the "current" state they are listed under. -/// -/// FIXME: all operations are placeholders that only modify the in-memory list. @MainActor final class VMSnapshotList: ObservableObject { /// Saved snapshots, newest first @@ -83,19 +81,24 @@ final class VMSnapshotList: ObservableObject { @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 = true + @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 - var canOverwriteBase: Bool { - canCreate && snapshot(for: currentParentID)?.canDelete == true - } + @Published private(set) var canOverwriteBase: Bool = false - private let vm: VMData + /// 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] = [:] - /// Returns the list for a VM, keeping it alive so placeholder edits survive navigation. + 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 } @@ -106,6 +109,25 @@ final class VMSnapshotList: ObservableObject { 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 @@ -130,80 +152,87 @@ final class VMSnapshotList: ObservableObject { // MARK: - Operations - /// FIXME: read the snapshots from the VM + /// 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 { - let title = title.trimmingCharacters(in: .whitespacesAndNewlines) - guard !title.isEmpty, let index = snapshots.firstIndex(where: { $0.id == snapshot.id }) else { - return - } - snapshots[index].title = title - snapshots[index].dateModified = Date() + 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 { - logger.debug("FIXME: saving the current state as a snapshot is not implemented") - let now = Date() - let snapshot = VMSnapshot(id: UUID(), - title: defaultTitle, - parentID: currentParentID, - dateCreated: now, - dateModified: now, - size: 0, - screenshot: vm.screenshotImage, - hasState: vm.hasSuspendState, - isOrphaned: false) - snapshots.insert(snapshot, at: 0) - currentParentID = snapshot.id + 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 { - logger.debug("FIXME: overwriting a snapshot is not implemented") - guard let index = snapshots.firstIndex(where: { $0.id == currentParentID }) else { + guard let currentParentID = currentParentID else { return } - snapshots[index].dateModified = Date() - snapshots[index].screenshot = vm.screenshotImage - snapshots[index].hasState = vm.hasSuspendState + try await UTMSnapshotService.overwriteSnapshot(currentParentID, on: try wrapped) + try await refresh() } - /// Snapshots based on a deleted snapshot are handed to what it was based on so the remaining lineage stays linked. func delete(_ snapshot: VMSnapshot) async throws { - logger.debug("FIXME: deleting a snapshot is not implemented") - for index in snapshots.indices where snapshots[index].parentID == snapshot.id { - snapshots[index].parentID = snapshot.parentID - } - if currentParentID == snapshot.id { - currentParentID = snapshot.parentID - } - snapshots.removeAll { $0.id == snapshot.id } + 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 { - logger.debug("FIXME: restoring a snapshot is not implemented") - currentParentID = snapshot.id + 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 { - logger.debug("FIXME: deleting the suspended state is not implemented") - } - - private var defaultTitle: String { - var number = snapshots.count + 1 - var title: String - repeat { - title = String.localizedStringWithFormat(NSLocalizedString("Snapshot %lld", comment: "VMSnapshotData"), number) - number += 1 - } while snapshots.contains { $0.title == title } - return title + try await wrapped.deleteSnapshot(name: nil) + try await refresh() } } From b1e1b12a0585df509b3c61f94e1dfe8faab4a889 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:14:39 -0500 Subject: [PATCH 9/9] scripting: model snapshots as scriptable objects The snapshot commands could only name a snapshot and could not say anything about it. A script could not tell when a snapshot was taken, how much it holds, whether restoring it resumes the guest or starts it up, or whether its data has gone missing, and there was no way to rename one or to throw away a suspended state, both of which the window offers. Make a snapshot an object instead. A virtual machine now has snapshots as elements, each carrying those details, so a script can pick one out and read it: tell application "UTM" set vm to virtual machine named "Debian" set candidates to (snapshots of vm whose includes running state) restore (item 1 of candidates) end tell Renaming is setting the name, and the commands that act on a snapshot take the snapshot itself. Creating one returns it. Two more commands cover what the window can do and scripting could not: scanning the images for snapshots made by other tools, and discarding a saved state so the guest starts up instead of resuming. This replaces the snapshot commands of the 5.0.5 beta, which addressed a snapshot by name through its virtual machine, so scripts and utmctl calls written against them have to be updated. Snapshots are addressed by name, so names are now kept apart the way virtual machine names are, by appending a number. Deleting a snapshot is its own command rather than the standard delete, which coerces a specifier for anything inside a virtual machine into the virtual machine itself; deleting that would throw away far more than was asked for. The delete command refuses such a specifier for the same reason. UTMScripting.swift is regenerated from the sdef; only the hunks that changed are taken. Assisted-by: Claude:claude-opus-5 --- Scripting/UTM.sdef | 90 +++++--- Scripting/UTMScripting.swift | 26 ++- Scripting/UTMScriptingDeleteCommand.swift | 6 + Scripting/UTMScriptingSnapshotImpl.swift | 114 ++++++++++ .../UTMScriptingVirtualMachineImpl.swift | 51 +++-- Services/UTMSnapshotService.swift | 55 ++--- UTM.xcodeproj/project.pbxproj | 4 + utmctl/UTMCtl.swift | 202 +++++++++++++++--- 8 files changed, 433 insertions(+), 115 deletions(-) create mode 100644 Scripting/UTMScriptingSnapshotImpl.swift 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 ac2a2270..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,36 +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 - return try await UTMSnapshotService.listSnapshots(on: vm) + 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/UTMSnapshotService.swift b/Services/UTMSnapshotService.swift index 66d42579..fc5e1d14 100644 --- a/Services/UTMSnapshotService.swift +++ b/Services/UTMSnapshotService.swift @@ -63,6 +63,11 @@ enum UTMSnapshotService { 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. /// /// Snapshots can be managed whether the VM is running or not, but not everything can be done @@ -125,7 +130,7 @@ enum UTMSnapshotService { // the VM still resumes from the state which is now the one of the snapshot manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier) } - let snapshot = Entry(title: title ?? manifest.defaultTitle, + let snapshot = Entry(title: title.map { manifest.uniqueTitle($0) } ?? manifest.defaultTitle, parentID: manifest.currentParentID, date: entry.date, size: entry.size, @@ -194,7 +199,8 @@ enum UTMSnapshotService { guard !title.isEmpty && snapshot.title != title else { return snapshot } - snapshot.title = title + // 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 @@ -332,36 +338,20 @@ enum UTMSnapshotService { } } - // MARK: - Snapshots by name + // MARK: - Reconciling with the backend - /// Create a snapshot or overwrite the one with the same title. - static func createSnapshot(name: String, on vm: any UTMVirtualMachine) async throws { - if let snapshot = try await timeline(for: vm).snapshots.first(where: { $0.title == name }) { - try await overwriteSnapshot(snapshot.id, on: vm) - } else { - try await createSnapshot(title: name, on: vm) + /// 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 } - } - - /// Titles of all snapshots, newest first - static func listSnapshots(on vm: any UTMVirtualMachine) async throws -> [String] { - _ = try backend(for: vm) - return try await timeline(for: vm).snapshots.map { $0.title } - } - - static func restoreSnapshot(name: String, on vm: any UTMVirtualMachine) async throws { - try await restoreSnapshot(try await snapshot(named: name, on: vm).id, on: vm) - } - - static func deleteSnapshot(name: String, on vm: any UTMVirtualMachine) async throws { - try await deleteSnapshot(try await snapshot(named: name, on: vm).id, on: vm) - } - - private static func snapshot(named name: String, on vm: any UTMVirtualMachine) async throws -> UTMSnapshot { - guard let snapshot = try await timeline(for: vm).snapshots.first(where: { $0.title == name }) else { - throw UTMSnapshotError.notFound(name) - } - return snapshot + try await importUnlinkedSnapshots(from: backend, on: vm) + try await identifyOrphanedSnapshots(on: vm) } // MARK: - Helpers @@ -539,10 +529,11 @@ private extension UTMSnapshotManifest { return title } - func uniqueTitle(_ base: String) -> String { + /// - 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 }) { + while snapshots.contains(where: { $0.title == title && $0.id != id }) { title = "\(base) \(number)" number += 1 } diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 9bda9502..1d47b29e 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -974,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 */; }; @@ -2167,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 = ""; }; @@ -3225,6 +3227,7 @@ CE59A7AF2AA69AE400E5FFBD /* UTMScriptingUSBDeviceImpl.swift */, CEFE98E029485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift */, CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */, + 38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */, CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */, CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */, CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */, @@ -4064,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 */, 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!() } } }