Merge pull request #7896 from utmapp/feature/snapshots-gui

snapshots: manage snapshots of QEMU and Apple VMs
This commit is contained in:
osy
2026-09-22 13:02:41 -05:00
committed by GitHub
25 changed files with 3564 additions and 265 deletions
@@ -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
}
}
@@ -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)
@@ -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 {
+343
View File
@@ -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<T>(_ 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<T>(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
}
}
+123 -44
View File
@@ -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<Void, Never>?
private let updateIpIntervalNs = UInt64(10 * 1_000_000_000)
@@ -51,53 +63,28 @@ struct VMDetailsView: View {
Spacer()
}
} else {
ScrollView {
Screenshot(vm: vm, large: regularScreenSizeClass)
let notes = vm.detailsNotes ?? ""
if regularScreenSizeClass && !notes.isEmpty {
HStack(alignment: .top) {
Details(vm: vm, sizeLabel: sizeLabel)
.frame(maxWidth: .infinity)
Text(notes)
.font(.body)
.frame(maxWidth: .infinity, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
.padding([.leading, .trailing])
}.padding([.leading, .trailing])
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
.padding([.leading, .trailing, .bottom])
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
.padding([.leading, .trailing, .bottom])
}
ScrollViewReader { scrollProxy in
ScrollView {
Screenshot(vm: vm, large: regularScreenSizeClass)
.id(ScrollAnchor.screenshot)
#if WITH_REMOTE // FIXME: implement remote feature
detailsPane
#else
let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuConfig)
.padding([.leading, .trailing, .bottom])
if let wrapped = vm.wrapped, UTMSnapshotService.isSupported(for: wrapped) {
tabs(scrollProxy: scrollProxy)
} else {
detailsPane
}
#endif
} else {
VStack {
Details(vm: vm, sizeLabel: sizeLabel)
if !notes.isEmpty {
Text(notes)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
}
#else
let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuConfig)
#endif
}.padding([.leading, .trailing, .bottom])
}
}.labelStyle(DetailsLabelStyle())
#if !WITH_REMOTE
.onChange(of: selectedTab) { tab in
withAnimation {
scrollProxy.scrollTo(tab == .snapshots ? ScrollAnchor.tabs : ScrollAnchor.screenshot, anchor: .top)
}
}
#endif
}
.modifier(VMOptionalNavigationTitleModifier(vm: vm))
.modifier(VMToolbarModifier(vm: vm, bottom: !regularScreenSizeClass))
.sheet(isPresented: $data.showSettingsModal) {
@@ -128,6 +115,98 @@ struct VMDetailsView: View {
}
}
#if !WITH_REMOTE
/// Panes below the screenshot that is shared by all of them.
@ViewBuilder private func tabs(scrollProxy: ScrollViewProxy) -> some View {
let snapshotsPane = VMSnapshotsView(vm: vm) { id in
withAnimation {
scrollProxy.scrollTo(id)
}
}
#if os(macOS)
TabView(selection: $selectedTab) {
detailsPane
.padding(.top)
.tabItem { Text("Details") }
.tag(Tab.details)
snapshotsPane
.padding()
.tabItem { Text("Snapshots") }
.tag(Tab.snapshots)
}.padding([.leading, .trailing, .bottom])
.padding(.top, 8)
.id(ScrollAnchor.tabs)
#else
Picker("View", selection: $selectedTab) {
Text("Details").tag(Tab.details)
Text("Snapshots").tag(Tab.snapshots)
}.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 400)
.padding([.leading, .trailing, .top])
.padding(.bottom, 8)
.id(ScrollAnchor.tabs)
switch selectedTab {
case .details:
detailsPane
case .snapshots:
snapshotsPane
.padding([.leading, .trailing, .bottom])
}
#endif
}
#endif
/// A single view, because a tab view makes a separate tab of every view it is given
@ViewBuilder private var detailsPane: some View {
VStack {
let notes = vm.detailsNotes ?? ""
if regularScreenSizeClass && !notes.isEmpty {
HStack(alignment: .top) {
Details(vm: vm, sizeLabel: sizeLabel)
.frame(maxWidth: .infinity)
Text(notes)
.font(.body)
.frame(maxWidth: .infinity, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
.padding([.leading, .trailing])
}.padding([.leading, .trailing])
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
.padding([.leading, .trailing, .bottom])
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
.padding([.leading, .trailing, .bottom])
}
#else
let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuConfig)
.padding([.leading, .trailing, .bottom])
#endif
} else {
VStack {
Details(vm: vm, sizeLabel: sizeLabel)
if !notes.isEmpty {
Text(notes)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
}
#else
let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuConfig)
#endif
}.padding([.leading, .trailing, .bottom])
}
}.labelStyle(DetailsLabelStyle())
}
#if !WITH_REMOTE
private func clearGuestIPs() {
guard let qemuVM = vm.wrapped as? UTMQemuVirtualMachine else {
+886
View File
@@ -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<Bool> {
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<Bool>.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<Thumbnail: View, Title: View, Details: View, Actions: View>: 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)
}
}
}
+7
View File
@@ -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")
}
}
}
+238
View File
@@ -0,0 +1,238 @@
//
// Copyright © 2026 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
/// A saved state of a virtual machine as shown in the snapshots list.
struct VMSnapshot: Identifiable, Equatable {
let id: UUID
/// User-editable display name
var title: String
/// Snapshot the current state was based on when this one was created, `nil` if there was none
var parentID: UUID?
var dateCreated: Date
/// Last time the snapshot was renamed or overwritten
var dateModified: Date
/// Bytes occupied on disk
var size: Int64
/// Screen contents when the snapshot was taken, if one was captured
var screenshot: PlatformImage?
/// Includes the running state of the VM, otherwise it starts up from the saved disks
var hasState: Bool
/// The saved state is missing so the snapshot can only be deleted
var isOrphaned: Bool
/// The saved state does not cover every drive so the snapshot cannot be restored
var isIncomplete: Bool = false
/// Possible while the VM is in its current state, which differs between running and not
var canRestore: Bool = true
/// Possible while the VM is in its current state, which differs between running and not
var canDelete: Bool = true
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
&& lhs.title == rhs.title
&& lhs.parentID == rhs.parentID
&& lhs.dateCreated == rhs.dateCreated
&& lhs.dateModified == rhs.dateModified
&& lhs.size == rhs.size
&& lhs.screenshot === rhs.screenshot
&& lhs.hasState == rhs.hasState
&& lhs.isOrphaned == rhs.isOrphaned
&& lhs.isIncomplete == rhs.isIncomplete
&& lhs.canRestore == rhs.canRestore
&& lhs.canDelete == rhs.canDelete
}
}
/// Snapshots of a single VM along with the "current" state they are listed under.
@MainActor
final class VMSnapshotList: ObservableObject {
/// Saved snapshots, newest first
@Published private(set) var snapshots: [VMSnapshot] = []
/// Snapshot the current state is based on, `nil` if there is none
@Published private(set) var currentParentID: UUID?
/// Nothing changed since the snapshot the current state is based on
@Published private(set) var isCurrentStateSaved: Bool = false
/// The current state can be saved while the VM is in its current state
@Published private(set) var canCreate: Bool = false
/// The snapshot the current state is based on can be replaced while the VM is in its current state
@Published private(set) var canOverwriteBase: Bool = false
/// Weak so that the cache does not keep a VM alive after it leaves the library
private weak var vm: VMData?
/// Screenshots by file
private var screenshots: [URL: PlatformImage] = [:]
private static var cache: [UUID: VMSnapshotList] = [:]
private var manifestObserver: (any NSObjectProtocol)?
/// Returns the list for a VM, keeping it alive so it is not read again every time it is shown.
static func list(for vm: VMData) -> VMSnapshotList {
cache = cache.filter { $0.value.vm != nil }
if let list = cache[vm.id] {
return list
}
let list = VMSnapshotList(vm: vm)
cache[vm.id] = list
return list
}
private init(vm: VMData) {
self.vm = vm
// snapshots also change through scripting while they are shown
manifestObserver = NotificationCenter.default.addObserver(forName: UTMSnapshotService.didChangeNotification, object: nil, queue: .main) { [weak self] notification in
let bundleURL = notification.object as? URL
Task { @MainActor [weak self] in
guard let self = self, let bundleURL = bundleURL, bundleURL.standardizedFileURL == self.vm?.wrapped?.pathUrl.standardizedFileURL else {
return
}
try? await self.refresh()
}
}
}
private var wrapped: any UTMVirtualMachine {
get throws {
guard let wrapped = vm?.wrapped else {
throw UTMSnapshotError.notSupported
}
return wrapped
}
}
// MARK: - Lineage
func snapshot(for id: UUID?) -> VMSnapshot? {
guard let id = id else {
return nil
}
return snapshots.first { $0.id == id }
}
/// Identifiers of every ancestor starting at `id` (inclusive) and walking up the parents.
func lineage(from id: UUID?) -> Set<UUID> {
var lineage = Set<UUID>()
var next = snapshot(for: id)
while let snapshot = next, !lineage.contains(snapshot.id) {
lineage.insert(snapshot.id)
next = self.snapshot(for: snapshot.parentID)
}
return lineage
}
// MARK: - Operations
/// Read the snapshots of the VM. The first time this adds those made by other tools.
func refresh() async throws {
let wrapped = try wrapped
let timeline = try await UTMSnapshotService.timeline(for: wrapped)
snapshots = timeline.snapshots.map { snapshot in
VMSnapshot(id: snapshot.id,
title: snapshot.title,
parentID: snapshot.parentID,
dateCreated: snapshot.dateCreated,
dateModified: snapshot.dateModified,
size: snapshot.size,
screenshot: screenshot(at: snapshot.screenshotURL),
hasState: snapshot.hasState,
isOrphaned: snapshot.isOrphaned,
isIncomplete: snapshot.isIncomplete,
canRestore: !snapshot.isOrphaned && !snapshot.isIncomplete && UTMSnapshotService.isSupported(.restore, for: snapshot, on: wrapped),
canDelete: UTMSnapshotService.isSupported(.delete, for: snapshot, on: wrapped))
}
// pictures of snapshots that are gone are not needed anymore
let screenshotURLs = Set(timeline.snapshots.compactMap { $0.screenshotURL })
screenshots = screenshots.filter { screenshotURLs.contains($0.key) }
currentParentID = timeline.currentParentID
isCurrentStateSaved = timeline.isCurrentStateSaved
canCreate = UTMSnapshotService.isSupported(.create, on: wrapped)
// overwriting saves the current state and deletes what the snapshot held
canOverwriteBase = canCreate && snapshot(for: currentParentID)?.canDelete == true
}
private func screenshot(at url: URL?) -> PlatformImage? {
guard let url = url else {
return nil
}
if screenshots[url] == nil {
screenshots[url] = PlatformImage(contentsOfURL: url)
}
return screenshots[url]
}
func rename(_ snapshot: VMSnapshot, to title: String) async throws {
try UTMSnapshotService.renameSnapshot(snapshot.id, to: title, on: try wrapped)
try await refresh()
}
/// Saves the current state as a new snapshot based on the one the current state was based on.
/// - Returns: Identifier of the new snapshot
@discardableResult
func createSnapshot() async throws -> UUID {
let snapshot = try await UTMSnapshotService.createSnapshot(on: try wrapped)
try await refresh()
return snapshot.id
}
/// Replaces what the snapshot the current state is based on holds with the current state.
func overwriteBase() async throws {
guard let currentParentID = currentParentID else {
return
}
try await UTMSnapshotService.overwriteSnapshot(currentParentID, on: try wrapped)
try await refresh()
}
func delete(_ snapshot: VMSnapshot) async throws {
try await UTMSnapshotService.deleteSnapshot(snapshot.id, on: try wrapped)
try await refresh()
}
/// Replaces the current state with `snapshot`. If the VM is not running, it resumes from it when started.
func restore(_ snapshot: VMSnapshot) async throws {
do {
try await UTMSnapshotService.restoreSnapshot(snapshot.id, on: try wrapped)
} catch {
// show snapshots found to be orphaned
try? await refresh()
throw error
}
try await refresh()
}
/// Discards the state the VM is suspended to so that it starts up normally.
func deleteSuspendState() async throws {
try await wrapped.deleteSnapshot(name: nil)
try await refresh()
}
}
+2 -1
View File
@@ -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 {
+65 -25
View File
@@ -127,32 +127,32 @@
</parameter>
</command>
<command name="create snapshot" code="UTMvcrsn" description="Create or replace a named full-VM snapshot (RAM, devices, and disk) for a QEMU virtual machine. The VM must be running or paused.">
<command name="create snapshot" code="UTMvcrsn" description="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.">
<direct-parameter description="Virtual machine to snapshot." type="virtual machine"/>
<parameter name="named" code="SnNm" description="The name of the snapshot." type="text">
<parameter name="named" code="SnNm" description="Name for the new snapshot. A name already in use gets a number appended. If omitted, a name is generated." type="text" optional="yes">
<cocoa key="snapshotName"/>
</parameter>
<result description="The snapshot that was created." type="snapshot"/>
</command>
<command name="list snapshots" code="UTMvlssn" description="List the names of all named snapshots for a stopped QEMU virtual machine, newest first.">
<direct-parameter description="Virtual machine to list snapshots for." type="virtual machine"/>
<result description="List of snapshot names.">
<type type="text" list="yes"/>
</result>
<command name="restore" code="UTMvrssn" description="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.">
<direct-parameter description="Snapshot to restore." type="snapshot"/>
</command>
<command name="restore snapshot" code="UTMvrssn" description="Restore a stopped QEMU virtual machine to a named snapshot.">
<direct-parameter description="Virtual machine to restore." type="virtual machine"/>
<parameter name="named" code="SnNm" description="The name of the snapshot to restore." type="text">
<cocoa key="snapshotName"/>
</parameter>
<command name="delete snapshot" code="UTMvdlsn" description="Delete a snapshot. Its data is deleted with it, there is no confirmation!">
<direct-parameter description="Snapshot to delete." type="snapshot"/>
</command>
<command name="delete snapshot" code="UTMvdlsn" description="Delete a named snapshot from a QEMU virtual machine.">
<direct-parameter description="Virtual machine owning the snapshot." type="virtual machine"/>
<parameter name="named" code="SnNm" description="The name of the snapshot to delete." type="text">
<cocoa key="snapshotName"/>
</parameter>
<command name="overwrite" code="UTMvovsn" description="Replace what a snapshot holds with the current state of the virtual machine. The snapshot keeps its name and its place among the others.">
<direct-parameter description="Snapshot to overwrite." type="snapshot"/>
</command>
<command name="scan snapshots" code="UTMvscsn" description="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.">
<direct-parameter description="Virtual machine to scan." type="virtual machine"/>
</command>
<command name="discard saved state" code="UTMvdcss" description="Throw away the state a suspended virtual machine would resume from, so that it starts up normally instead. Snapshots are not affected.">
<direct-parameter description="Virtual machine to discard the saved state of." type="virtual machine"/>
</command>
<command name="delete" code="coredelo" description="Delete a virtual machine. All data will be deleted, there is no confirmation!">
@@ -208,6 +208,11 @@
description="Serial ports exposed by the guest to the host to access.">
<cocoa key="serialPorts"/>
</element>
<element type="snapshot" access="r"
description="Saved states of this virtual machine, newest first.">
<cocoa key="snapshots"/>
</element>
<responds-to command="start">
<cocoa method="start:"/>
@@ -233,16 +238,12 @@
<cocoa method="createSnapshot:"/>
</responds-to>
<responds-to command="list snapshots">
<cocoa method="listSnapshots:"/>
<responds-to command="scan snapshots">
<cocoa method="scanSnapshots:"/>
</responds-to>
<responds-to command="restore snapshot">
<cocoa method="restoreSnapshot:"/>
</responds-to>
<responds-to command="delete snapshot">
<cocoa method="deleteSnapshot:"/>
<responds-to command="discard saved state">
<cocoa method="discardSavedState:"/>
</responds-to>
<responds-to command="delete">
@@ -251,6 +252,45 @@
</class>
<class name="snapshot" code="SnAp" description="A saved state of a virtual machine that it can be returned to." plural="snapshots">
<cocoa class="UTMScriptingSnapshotImpl"/>
<property name="id" code="ID " type="text" access="r"
description="The unique identifier of the snapshot."/>
<property name="name" code="pnam" type="text"
description="Name shown for the snapshot. Setting it renames the snapshot; a name already in use gets a number appended."/>
<property name="creation date" code="ascd" type="date" access="r"
description="When the snapshot was first saved."/>
<property name="modification date" code="asmo" type="date" access="r"
description="When the snapshot was last renamed or overwritten."/>
<property name="size" code="SnSz" type="real" access="r"
description="Bytes the snapshot occupies."/>
<property name="includes running state" code="SnRs" type="boolean" access="r"
description="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."/>
<property name="data missing" code="SnDm" type="boolean" access="r"
description="Whether the data behind the snapshot has gone missing, in which case it can only be deleted."/>
<property name="based on" code="SnBo" type="text" access="r"
description="Identifier of the snapshot the current state was based on when this one was saved, or empty if it was not based on any."/>
<responds-to command="restore">
<cocoa method="restore:"/>
</responds-to>
<responds-to command="overwrite">
<cocoa method="overwrite:"/>
</responds-to>
<responds-to command="delete snapshot">
<cocoa method="deleteSnapshot:"/>
</responds-to>
</class>
<class name="serial port" code="SeRi" description="A serial port in the guest that can be connected to from the host." plural="serial ports">
<cocoa class="UTMScriptingSerialPortImpl"/>
<property name="id" code="ID " type="integer" access="r"
+22 -4
View File
@@ -21,6 +21,7 @@ public enum UTMScripting: String {
case guestFile = "guest file"
case guestProcess = "guest process"
case serialPort = "serial port"
case snapshot = "snapshot"
case usbDevice = "usb device"
case virtualMachine = "virtual machine"
}
@@ -231,6 +232,7 @@ extension SBObject: UTMScriptingWindow {}
// MARK: UTMScriptingVirtualMachine
@objc public protocol UTMScriptingVirtualMachine: SBObjectProtocol, UTMScriptingGenericMethods {
@objc optional func serialPorts() -> 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.
@@ -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
+114
View File
@@ -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
}
}
+30 -22
View File
@@ -91,6 +91,28 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
}
}
@objc var snapshots: [UTMScriptingSnapshotImpl] {
UTMSnapshotService.snapshots(for: vm).map { UTMScriptingSnapshotImpl(snapshot: $0, parent: self) }
}
/// Resolve `snapshot id "..." of virtual machine ...`
///
/// Without this a specifier for a snapshot cannot be evaluated and falls back to this virtual
/// machine, which would aim a command meant for one snapshot at the whole VM.
@objc(valueInSnapshotsWithUniqueID:)
func valueInSnapshots(withUniqueID id: Any) -> UTMScriptingSnapshotImpl? {
guard let id = id as? String else {
return nil
}
return snapshots.first { $0.id == id }
}
/// Resolve `snapshot "..." of virtual machine ...`
@objc(valueInSnapshotsWithName:)
func valueInSnapshots(withName name: String) -> UTMScriptingSnapshotImpl? {
snapshots.first { $0.name == name }
}
var guestAgent: QEMUGuestAgent! {
get async {
await (vm as? UTMQemuVirtualMachine)?.guestAgent
@@ -290,37 +312,23 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
@objc func createSnapshot(_ command: NSScriptCommand) {
let name = command.evaluatedArguments?["snapshotName"] as? String
withScriptCommand(command) { [self] in
guard let name = name else {
throw ScriptingError.invalidParameter
}
try await UTMSnapshotService.createSnapshot(name: name, on: vm)
let snapshot = try await UTMSnapshotService.createSnapshot(title: name, on: vm)
return UTMScriptingSnapshotImpl(snapshot: snapshot, parent: self)
}
}
@objc func listSnapshots(_ command: NSScriptCommand) {
@objc func scanSnapshots(_ command: NSScriptCommand) {
withScriptCommand(command) { [self] in
let entries = try await UTMSnapshotService.listSnapshots(on: vm)
return entries.map { $0.name }
try await UTMSnapshotService.scanSnapshots(on: vm)
}
}
@objc func restoreSnapshot(_ command: NSScriptCommand) {
let name = command.evaluatedArguments?["snapshotName"] as? String
@objc func discardSavedState(_ command: NSScriptCommand) {
withScriptCommand(command) { [self] in
guard let name = name else {
throw ScriptingError.invalidParameter
guard vm.registryEntry.isSuspended else {
return
}
try await UTMSnapshotService.restoreSnapshot(name: name, on: vm)
}
}
@objc func deleteSnapshot(_ command: NSScriptCommand) {
let name = command.evaluatedArguments?["snapshotName"] as? String
withScriptCommand(command) { [self] in
guard let name = name else {
throw ScriptingError.invalidParameter
}
try await UTMSnapshotService.deleteSnapshot(name: name, on: vm)
try await vm.deleteSnapshot(name: nil)
}
}
+282 -9
View File
@@ -48,6 +48,7 @@ enum UTMAppleDiskImage {
}
private static let bytesInMib = 1048576
fileprivate static let layerFileExtension = "asif"
/// Block size used when creating images, matches the legacy DiskImages2 path
private static let blockSize = 512
@@ -95,12 +96,25 @@ enum UTMAppleDiskImage {
}
}
/// Files beside an image that its snapshots stacked on it, found by name so that this works
/// where the layers themselves cannot be read
static func layerFileURLs(of imageURL: URL) -> [URL] {
let prefix = imageURL.lastPathComponent + "."
// an image outside of the bundle may be in a directory that cannot be read
let siblings = (try? FileManager.default.contentsOfDirectory(at: imageURL.deletingLastPathComponent(), includingPropertiesForKeys: nil)) ?? []
return siblings.filter { $0.lastPathComponent.hasPrefix(prefix) && $0.pathExtension == layerFileExtension }
}
/// Change the size of the virtual disk of an existing image
/// - Parameters:
/// - url: Location of the image
/// - sizeMib: New size of the virtual disk in MiB
@available(macOS 14, *)
static func resize(_ url: URL, toSizeMib sizeMib: Int) throws {
// every layer stacked on the image was built on it as it is
guard layerFileURLs(of: url).isEmpty else {
throw UTMAppleDiskImageError.hasSnapshots
}
if #available(macOS 27, *) {
let image = try DiskImageKit.DiskImage(opening: .open(url: url, mode: .readWrite))
try image.truncate(blockCount: sizeMib * bytesInMib / image.blockSize.rawValue)
@@ -114,19 +128,278 @@ enum UTMAppleDiskImage {
@available(macOS 27, *)
extension UTMAppleDiskImage {
/// Create an attachment whose guest writes land in a new copy-on-write layer
/// Copy-on-write layer stacked on the image of a drive.
///
/// The image at `url` is opened read-only and is never modified. Any existing file at `overlayURL` is replaced.
/// Layers are kept next to the image as `<image>.<name>.asif`. Each one only holds what was
/// written while it was on top, so it depends on the layers below it.
struct Layer: Equatable {
/// Name of the layer that guest writes go to
static let currentName = "current"
fileprivate static let fileExtension = layerFileExtension
fileprivate static let unlinkedMarker = "unlinked"
let url: URL
/// `currentName` or the snapshot the layer was frozen as
let name: String
/// Not a snapshot anymore but still below other layers
let isUnlinked: Bool
let uuid: UUID
/// Layer right below this one, `nil` if that is the image itself
let parentUUID: UUID?
var isCurrent: Bool {
name == Self.currentName
}
fileprivate static func url(for imageURL: URL, name: String, isUnlinked: Bool = false) -> URL {
var url = imageURL.appendingPathExtension(name)
if isUnlinked {
url.appendPathExtension(unlinkedMarker)
}
return url.appendingPathExtension(fileExtension)
}
}
/// Find the layers of an image.
///
/// The current layer is left out while it cannot be opened because a VM is using it.
static func layers(of imageURL: URL) throws -> [Layer] {
let prefix = imageURL.lastPathComponent + "."
let candidates = layerFileURLs(of: imageURL)
guard !candidates.isEmpty else {
// most images have no layers, leave them alone
return []
}
let baseUUID = try DiskImageKit.DiskImage(opening: .open(url: imageURL, mode: .readOnly)).layerUUID
return candidates.compactMap { url -> Layer? in
var name = String(url.deletingPathExtension().lastPathComponent.dropFirst(prefix.count))
let isUnlinked = name.hasSuffix("." + Layer.unlinkedMarker)
if isUnlinked {
name.removeLast(Layer.unlinkedMarker.count + 1)
}
guard !name.isEmpty, let image = try? DiskImageKit.DiskImage(opening: .open(url: url, mode: .readOnly)), let uuid = image.layerUUID else {
return nil
}
// an image without an identifier cannot be told apart from no layer
let parentUUID = image.parentUUID == baseUUID ? nil : image.parentUUID
return Layer(url: url, name: name, isUnlinked: isUnlinked, uuid: uuid, parentUUID: parentUUID)
}
}
/// Layers from the one right above the image up to `top`
///
/// - Throws: `UTMAppleDiskImageError.layerMissing` when a layer below `top` cannot be read,
/// because opening the rest without it would hide everything that layer holds.
private static func chain(endingAt top: Layer?, in layers: [Layer]) throws -> [Layer] {
var chain = [Layer]()
var next = top
while let layer = next, !chain.contains(layer) {
chain.insert(layer, at: 0)
next = layers.first { $0.uuid == layer.parentUUID }
guard next != nil || layer.parentUUID == nil else {
throw UTMAppleDiskImageError.layerMissing
}
}
return chain
}
/// Open an image along with some of its layers, all of them read-only unless `isTopWritable`.
private static func open(_ imageURL: URL, chain: [Layer], isTopWritable: Bool = false) throws -> DiskImageKit.DiskImage {
var image = try DiskImageKit.DiskImage(opening: .open(url: imageURL, mode: chain.isEmpty && isTopWritable ? .readWrite : .readOnly))
for layer in chain {
let isWritable = isTopWritable && layer == chain.last
image = try image.appending(DiskImageKit.DiskImage(opening: .open(url: layer.url, mode: isWritable ? .readWrite : .readOnly)))
}
return image
}
/// Put a new layer on top of `chain` that guest writes go to from now on.
private static func createCurrentLayer(of imageURL: URL, above chain: [Layer]) throws {
let fileManager = FileManager.default
let url = Layer.url(for: imageURL, name: Layer.currentName)
// built under a name that is not taken for a layer, so the one it replaces stays until it exists
let pendingURL = imageURL.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).\(Layer.fileExtension)")
do {
_ = try open(imageURL, chain: chain).appending(ASIFCreationConfiguration.layer(url: pendingURL, type: .overlay))
if fileManager.fileExists(atPath: url.path) {
_ = try fileManager.replaceItemAt(url, withItemAt: pendingURL)
} else {
try fileManager.moveItem(at: pendingURL, to: url)
}
} catch {
try? fileManager.removeItem(at: pendingURL)
throw error
}
}
/// Create an attachment for an image that has layers or that should not be modified.
///
/// Any existing file at `overlayURL` is replaced.
/// - Parameters:
/// - url: Location of the base image
/// - overlayURL: Location of the overlay layer to create
/// - url: Location of the image
/// - includesLayers: Look for layers, which snapshots only stack on the images in the bundle
/// - overlayURL: If set, guest writes land in a new layer created here and nothing else is modified
/// - isReadOnly: The drive may not be written to, which a stack cannot be attached as
/// - cachingMode: Caching mode of the attachment
/// - synchronizationMode: Synchronization mode of the attachment
static func attachment(for url: URL, ephemeralOverlayAt overlayURL: URL, cachingMode: VZDiskImageCachingMode = .automatic, synchronizationMode: VZDiskImageSynchronizationMode = .full) throws -> VZDiskImageStorageDeviceAttachment {
try? FileManager.default.removeItem(at: overlayURL)
let base = try DiskImageKit.DiskImage(opening: .open(url: url, mode: .readOnly))
let stack = try base.appending(ASIFCreationConfiguration.layer(url: overlayURL, type: .overlay))
return try VZDiskImageStorageDeviceAttachment(diskImage: stack, cachingMode: cachingMode, synchronizationMode: synchronizationMode)
/// - Returns: `nil` if the image has no layers and can be attached by itself
static func attachment(for url: URL, includesLayers: Bool, ephemeralOverlayAt overlayURL: URL? = nil, isReadOnly: Bool = false, cachingMode: VZDiskImageCachingMode = .automatic, synchronizationMode: VZDiskImageSynchronizationMode = .full) throws -> VZDiskImageStorageDeviceAttachment? {
let layers = includesLayers ? try layers(of: url) : []
let current = layers.first { $0.isCurrent }
// writing to the image itself is only safe while nothing is stacked on it, otherwise it
// would change what every layer above it was built on
guard current != nil || !includesLayers || layerFileURLs(of: url).isEmpty else {
throw UTMAppleDiskImageError.layerMissing
}
let chain = try chain(endingAt: current, in: layers)
if let overlayURL = overlayURL {
try? FileManager.default.removeItem(at: overlayURL)
let stack = try open(url, chain: chain).appending(ASIFCreationConfiguration.layer(url: overlayURL, type: .overlay))
return try VZDiskImageStorageDeviceAttachment(diskImage: stack, cachingMode: cachingMode, synchronizationMode: synchronizationMode)
} else if chain.isEmpty {
return nil
} else if isReadOnly {
// the image alone is the drive as it was before the first snapshot
throw UTMAppleDiskImageError.readOnlyWithSnapshots
} else {
return try VZDiskImageStorageDeviceAttachment(diskImage: try open(url, chain: chain, isTopWritable: true), cachingMode: cachingMode, synchronizationMode: synchronizationMode)
}
}
/// Freeze what has been written to an image so far and direct further writes to a new layer.
///
/// The image must not be in use.
/// - Parameters:
/// - imageURL: Location of the image
/// - name: Name of the snapshot
static func createSnapshot(of imageURL: URL, name: String) throws {
let fileManager = FileManager.default
let snapshotURL = Layer.url(for: imageURL, name: name)
let current = try layers(of: imageURL).first(where: { $0.isCurrent })
// once there are layers only the one being written to may be frozen, or they would be lost
guard current != nil || layerFileURLs(of: imageURL).isEmpty else {
throw UTMAppleDiskImageError.layerMissing
}
if let current = current {
try fileManager.moveItem(at: current.url, to: snapshotURL)
// the date of a snapshot is when its layer was frozen, not when the guest last wrote to it
try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path)
} else {
// so far everything was written to the image itself
_ = try open(imageURL, chain: []).appending(ASIFCreationConfiguration.layer(url: snapshotURL, type: .overlay))
}
do {
let layers = try self.layers(of: imageURL)
try createCurrentLayer(of: imageURL, above: try chain(endingAt: layers.first { $0.name == name }, in: layers))
} catch {
// without a layer to write to the drive could not be used anymore
if let current = current {
try? fileManager.moveItem(at: snapshotURL, to: current.url)
} else {
try? fileManager.removeItem(at: snapshotURL)
}
throw error
}
}
/// Discard what was written to an image since a snapshot was created.
///
/// The image must not be in use.
static func restoreSnapshot(of imageURL: URL, name: String) throws {
try createCurrentLayer(of: imageURL, above: try snapshotChain(of: imageURL, name: name))
try removeUnusedLayers(of: imageURL)
}
/// Make sure that a snapshot can be restored, without changing anything.
static func validateSnapshot(of imageURL: URL, name: String) throws {
_ = try snapshotChain(of: imageURL, name: name)
}
private static func snapshotChain(of imageURL: URL, name: String) throws -> [Layer] {
let layers = try layers(of: imageURL)
guard let snapshot = layers.first(where: { $0.name == name && !$0.isUnlinked }) else {
throw UTMAppleDiskImageError.snapshotNotFound(name)
}
return try chain(endingAt: snapshot, in: layers)
}
/// Delete a snapshot. Its layer stays for as long as there are layers above it.
static func deleteSnapshot(of imageURL: URL, name: String) throws {
guard let snapshot = try layers(of: imageURL).first(where: { $0.name == name && !$0.isUnlinked }) else {
throw UTMAppleDiskImageError.snapshotNotFound(name)
}
try FileManager.default.moveItem(at: snapshot.url, to: Layer.url(for: imageURL, name: name, isUnlinked: true))
try removeUnusedLayers(of: imageURL)
}
/// Layers of an image that are still needed, given the snapshots that are still wanted.
///
/// A layer is needed when it is the one being written to, when it belongs to one of `names`,
/// or when it lies under a layer that is needed. Anything else is left over and may go.
/// - Parameters:
/// - imageURL: Location of the image
/// - names: Names of the snapshots to keep
static func preservedLayerURLs(of imageURL: URL, for names: Set<String>) 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<URL>()
for top in layers where top.isCurrent || names.contains(top.name) {
guard let chain = try? chain(endingAt: top, in: layers) else {
return fileURLs
}
for layer in chain {
preserved.insert(layer.url)
}
}
return Array(preserved)
}
/// Delete the layers of deleted snapshots that no other layer needs anymore
private static func removeUnusedLayers(of imageURL: URL) throws {
var layers = try layers(of: imageURL)
// the current layer is not found while it is in use so keep what may be below it
guard layers.contains(where: { $0.isCurrent }) || layers.isEmpty else {
return
}
while let unused = layers.first(where: { layer in layer.isUnlinked && !layers.contains { $0.parentUUID == layer.uuid } }) {
try FileManager.default.removeItem(at: unused.url)
layers.removeAll { $0 == unused }
}
}
}
enum UTMAppleDiskImageError: Error {
case snapshotNotFound(String)
case layerMissing
case hasSnapshots
case snapshotsRequireNewerOS
case readOnlyWithSnapshots
}
extension UTMAppleDiskImageError: LocalizedError {
var errorDescription: String? {
switch self {
case .snapshotNotFound(let name):
return String.localizedStringWithFormat(NSLocalizedString("The disk image has no snapshot '%@'.", comment: "UTMAppleDiskImage"), name)
case .layerMissing:
return NSLocalizedString("The disk image is incomplete because some of its data could not be read.", comment: "UTMAppleDiskImage")
case .hasSnapshots:
return NSLocalizedString("A drive cannot be resized once a snapshot has been taken of it.", comment: "UTMAppleDiskImage")
case .snapshotsRequireNewerOS:
return NSLocalizedString("This drive holds snapshots and can only be used on macOS 27 or later.", comment: "UTMAppleDiskImage")
case .readOnlyWithSnapshots:
return NSLocalizedString("A drive cannot be read only once a snapshot has been taken of it.", comment: "UTMAppleDiskImage")
}
}
}
+256
View File
@@ -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)
}
}
}
+42 -1
View File
@@ -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, *)
+40
View File
@@ -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
}
+210
View File
@@ -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
}
}
+80 -39
View File
@@ -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 {
+85
View File
@@ -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
}
+502 -81
View File
@@ -15,138 +15,559 @@
//
import Foundation
import Logging
private let snapshotLogger = Logger(label: "com.utmapp.UTM.snapshot") { label in
UTMLoggingSwift(label: label)
}
/// Coordinates named full-VM snapshots for the QEMU backend.
/// Manages the snapshots of a VM regardless of its backend and of whether it is running.
///
/// This is distinct from the internal suspend/resume feature which uses the reserved name
/// `"suspend"` and `registryEntry.isSuspended`; named snapshots never mark a VM as suspended.
/// The state of a snapshot is kept by a `UTMSnapshotBackend`. Everything else is kept in the
/// `UTMSnapshotManifest` of the bundle which links to that state. Only this service changes the
/// snapshots in a manifest so that the two stay consistent.
///
/// A snapshot never changes unless it is overwritten. Restoring one replaces the current state of
/// the VM with a copy and the snapshot it was restored from is remembered as what the current
/// state is based on, which is how snapshots get their lineage.
///
/// Suspending is closely related: the state a VM is suspended to can be kept as a snapshot and
/// restoring a snapshot makes a stopped VM resume from it. The VM itself keeps track of what it
/// resumes from through the suspend state operations of `UTMSnapshotManifest`.
@MainActor
enum UTMSnapshotService {
/// Snapshot name reserved for the internal suspend/resume feature.
private static let reservedSuspendName = "suspend"
fileprivate typealias Entry = UTMSnapshotManifest.Snapshot
/// Create a named snapshot capturing the full VM state (RAM + devices + disk).
/// Posted with the bundle of the VM as its object whenever its snapshots change
static let didChangeNotification = UTMSnapshotManifest.didChangeNotification
// MARK: - Reading
/// Snapshots can be managed for this VM, whether it is running or not
static func isSupported(for vm: any UTMVirtualMachine) -> Bool {
#if os(macOS)
if vm is UTMQemuVirtualMachine {
return true
} else if #available(macOS 27, *), vm is UTMAppleVirtualMachine {
return true
}
#endif
// qemu-img is needed for a VM that is not running and is only part of the macOS app
return false
}
/// Snapshots of a VM and how the current state relates to them. The first time, those that
/// are already in the backend are added.
static func timeline(for vm: any UTMVirtualMachine) async throws -> UTMSnapshotTimeline {
var manifest = try UTMSnapshotManifest.load(from: vm.pathUrl) ?? UTMSnapshotManifest()
if !manifest.isBackendImported, let backend = try? backend(for: vm), backend.isInventoryExact {
manifest = try await importUnlinkedSnapshots(from: backend, on: vm)
}
return UTMSnapshotTimeline(snapshots: manifest.snapshots.map { UTMSnapshot($0, on: vm) },
currentParentID: manifest.currentParentID,
isCurrentStateSaved: isCurrentStateSaved(in: manifest, on: vm))
}
/// Snapshots of a VM as they are recorded in the bundle, newest first, without consulting the backend
static func snapshots(for vm: any UTMVirtualMachine) -> [UTMSnapshot] {
(((try? UTMSnapshotManifest.load(from: vm.pathUrl)) ?? nil)?.snapshots ?? []).map { UTMSnapshot($0, on: vm) }
}
/// An operation is possible while the VM is in its current state.
///
/// QEMU replaces an existing snapshot with the same name.
static func createSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
_ = try requireQemuBackend(vm)
try validate(name: name)
if let error = vm.snapshotUnsupportedError {
throw error
/// Snapshots can be managed whether the VM is running or not, but not everything can be done
/// either way. A running QEMU can only deal with snapshots that include the running state and
/// the disks of a running Apple VM cannot be replaced.
/// - Parameters:
/// - operation: Operation to perform
/// - snapshot: Snapshot to restore or delete
/// - vm: VM the snapshot belongs to
static func isSupported(_ operation: UTMSnapshotOperation, for snapshot: UTMSnapshot? = nil, on vm: any UTMVirtualMachine) -> Bool {
guard let backend = try? backend(for: vm) else {
return false
}
// capturing RAM through the QEMU monitor requires the VM to be running or paused
guard vm.state == .started || vm.state == .paused else {
throw UTMSnapshotError.invalidVmState
if operation == .create && backend.imageURLs.isEmpty {
return false
}
snapshotLogger.debug("Creating snapshot '\(name)' on QEMU VM")
try await vm.saveSnapshot(name: name)
return backend.supports(operation, hasState: snapshot?.hasState ?? true)
}
/// List full-VM snapshots stored in the VM's bundled writable disk images, newest first.
static func listSnapshots(on vm: any UTMVirtualMachine) async throws -> [UTMQemuImage.QemuSnapshotInfo] {
let qemu = try requireQemuBackend(vm)
guard vm.state == .stopped else {
throw UTMSnapshotError.listRequiresStoppedVm
/// Nothing changed since the snapshot the current state is based on, which is known when the
/// VM is suspended to the state of that very snapshot.
///
/// Saving the current state again would only duplicate that snapshot.
private static func isCurrentStateSaved(in manifest: UTMSnapshotManifest, on vm: any UTMVirtualMachine) -> Bool {
// a VM that is not stopped may have run since it was suspended
guard vm.state == .stopped, let backend = try? backend(for: vm), let parentID = manifest.currentParentID else {
return false
}
var snapshotsByName = [String: UTMQemuImage.QemuSnapshotInfo]()
var imageURLs = qemu.config.drives.compactMap { drive -> URL? in
guard drive.imageType == .disk && !drive.isExternal && !drive.isReadOnly else {
return nil
let suspendIdentifier = vm.registryEntry.isSuspended ? manifest.suspendIdentifier ?? backend.defaultSuspendIdentifier : nil
return manifest[parentID]?.backendIdentifier == suspendIdentifier
}
fileprivate static func screenshotURL(for snapshot: Entry, on vm: any UTMVirtualMachine) -> URL? {
snapshot.screenshotName.map { UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent($0) }
}
// MARK: - Operations
/// Save the current state of a VM as a new snapshot.
/// - Parameters:
/// - title: Display name, a default is used if nil
/// - vm: VM that is either running or not
@discardableResult
static func createSnapshot(title: String? = nil, on vm: any UTMVirtualMachine) async throws -> UTMSnapshot {
let backend = try backend(for: vm)
guard !backend.imageURLs.isEmpty else {
throw UTMSnapshotError.noDrives
}
guard backend.supports(.create, hasState: true) else {
throw UTMSnapshotError.requiresStoppedVm
}
let suspendIdentifier = try suspendIdentifier(of: vm, in: backend)
let entry = try await backend.create(suspendIdentifier: suspendIdentifier)
logger.debug("Created snapshot '\(entry.identifier)'")
let screenshotName = saveScreenshot(of: vm)
let isSuspendStateKept = suspendIdentifier != nil && entry.hasState && vm.state != .started
let title = title.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.flatMap { $0.isEmpty ? nil : $0 }
let (snapshot, staleIdentifiers) = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> (Entry, [String]) in
if isSuspendStateKept {
// the VM still resumes from the state which is now the one of the snapshot
manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier)
}
return drive.imageURL
let snapshot = Entry(title: title.map { manifest.uniqueTitle($0) } ?? manifest.defaultTitle,
parentID: manifest.currentParentID,
date: entry.date,
size: entry.size,
hasState: entry.hasState,
backendIdentifier: entry.identifier,
screenshotName: screenshotName)
manifest.insert(snapshot)
manifest.currentParentID = snapshot.id
return (snapshot, manifest.staleIdentifiers)
}
if qemu.config.qemu.hasUefiBoot,
let efiVarsURL = qemu.config.qemu.efiVarsURL,
FileManager.default.fileExists(atPath: efiVarsURL.path) {
imageURLs.insert(efiVarsURL, at: 0)
await deleteStaleStates(staleIdentifiers, from: backend, on: vm)
return UTMSnapshot(snapshot, on: vm)
}
/// Replace what a snapshot holds with the current state of a VM.
///
/// Everything else about the snapshot stays, including the snapshots based on it.
static func overwriteSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws {
let backend = try backend(for: vm)
guard let original = try UTMSnapshotManifest.load(from: vm.pathUrl)?[id] else {
throw UTMSnapshotError.notFound(id.uuidString)
}
for imageURL in imageURLs {
let imageInfo = try await UTMQemuImage.info(image: imageURL)
for snapshot in imageInfo.snapshots ?? [] where snapshot.vmStateSize > 0 && snapshot.name != reservedSuspendName {
snapshotsByName[snapshot.name] = snapshot
guard !backend.imageURLs.isEmpty else {
throw UTMSnapshotError.noDrives
}
guard backend.supports(.create, hasState: true) && backend.supports(.delete, hasState: original.hasState) else {
throw UTMSnapshotError.requiresStoppedVm
}
let suspendIdentifier = try suspendIdentifier(of: vm, in: backend)
let entry = try await backend.create(suspendIdentifier: suspendIdentifier)
logger.debug("Overwriting snapshot '\(original.backendIdentifier)' with '\(entry.identifier)'")
let screenshotName = saveScreenshot(of: vm)
let isSuspendStateKept = suspendIdentifier != nil && entry.hasState && vm.state != .started
let staleIdentifiers = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> [String] in
if isSuspendStateKept {
manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier)
}
if var snapshot = manifest[id] {
snapshot.backendIdentifier = entry.identifier
snapshot.size = entry.size
snapshot.hasState = entry.hasState
snapshot.screenshotName = screenshotName
snapshot.dateModified = Date()
snapshot.isOrphaned = false
snapshot.isIncomplete = false
manifest[id] = snapshot
}
manifest.currentParentID = id
manifest.markStale(original.backendIdentifier)
return manifest.staleIdentifiers
}
await deleteStaleStates(staleIdentifiers, from: backend, on: vm)
if let screenshotName = original.screenshotName {
try? FileManager.default.removeItem(at: UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent(screenshotName))
}
}
/// - Returns: The snapshot with the title it ended up with
@discardableResult
static func renameSnapshot(_ id: UUID, to title: String, on vm: any UTMVirtualMachine) throws -> UTMSnapshot {
let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
let snapshot = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> Entry in
guard var snapshot = manifest[id] else {
throw UTMSnapshotError.notFound(id.uuidString)
}
guard !title.isEmpty && snapshot.title != title else {
return snapshot
}
// a snapshot is addressed by its title outside of the app, so keep them apart
snapshot.title = manifest.uniqueTitle(title, excluding: id)
snapshot.dateModified = Date()
manifest[id] = snapshot
return snapshot
}
return UTMSnapshot(snapshot, on: vm)
}
/// Delete a snapshot, along with its state once nothing else links to it.
static func deleteSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws {
let backend = try backend(for: vm)
guard let manifest = try UTMSnapshotManifest.load(from: vm.pathUrl), let snapshot = manifest[id] else {
throw UTMSnapshotError.notFound(id.uuidString)
}
guard backend.supports(.delete, hasState: snapshot.hasState) else {
throw UTMSnapshotError.requiresStoppedVm
}
var remaining = manifest
remaining.remove(id)
if !remaining.isReferenced(backendIdentifier: snapshot.backendIdentifier) {
do {
try await backend.delete(identifier: snapshot.backendIdentifier)
logger.debug("Deleted snapshot '\(snapshot.backendIdentifier)'")
} catch UTMSnapshotError.notFound(_) {
// orphaned
} catch {
// some drives may have lost the state already, so it must not look restorable
_ = try? await identifyOrphanedSnapshots(on: vm)
throw error
}
}
return snapshotsByName.values.sorted { $0.date > $1.date }
try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in
manifest.remove(id)
}
if let screenshotName = snapshot.screenshotName {
try? FileManager.default.removeItem(at: UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent(screenshotName))
}
}
/// Restore the VM to a previously captured named snapshot.
static func restoreSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
_ = try requireQemuBackend(vm)
try validate(name: name)
guard vm.state == .stopped else {
throw UTMSnapshotError.restoreRequiresStoppedVm
/// Replace the current state of a VM with a snapshot.
///
/// A running VM continues from the snapshot right away. Otherwise it resumes from it, or boots
/// from its disks if the snapshot has no running state, the next time it is started.
static func restoreSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws {
let backend = try backend(for: vm)
guard let snapshot = try UTMSnapshotManifest.load(from: vm.pathUrl)?[id] else {
throw UTMSnapshotError.notFound(id.uuidString)
}
guard try await listSnapshots(on: vm).contains(where: { $0.name == name }) else {
throw UTMSnapshotError.notFound(name)
guard backend.supports(.restore, hasState: snapshot.hasState) else {
throw UTMSnapshotError.requiresStoppedVm
}
snapshotLogger.debug("Restoring snapshot '\(name)' on QEMU VM")
try await vm.restoreSnapshot(name: name)
guard let entry = try await backend.entries().first(where: { $0.identifier == snapshot.backendIdentifier }) else {
try? await identifyOrphanedSnapshots(on: vm)
throw UTMSnapshotError.orphaned(snapshot.title)
}
guard entry.isComplete else {
try? await identifyOrphanedSnapshots(on: vm)
throw UTMSnapshotError.incomplete(snapshot.title)
}
// a state left named after a start that failed is replaced as well
let previousSuspendIdentifier = try suspendIdentifier(of: vm, in: backend)
let isResumedOnStart = try await backend.restore(entry)
logger.debug("Restored snapshot '\(entry.identifier)'")
let staleIdentifiers = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> [String] in
manifest.replaceSuspendState(previousSuspendIdentifier, with: isResumedOnStart ? entry.identifier : nil)
manifest.currentParentID = id
return manifest.staleIdentifiers
}
vm.registryEntry.isSuspended = isResumedOnStart
// the state that was suspended to is replaced
await deleteStaleStates(staleIdentifiers, from: backend, on: vm)
// the current state is a copy of the snapshot now, so show what the snapshot shows
restoreScreenshot(of: snapshot, on: vm)
}
/// Delete a named snapshot and its backend state.
static func deleteSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
_ = try requireQemuBackend(vm)
try validate(name: name)
// QEMU deletes the tag from the running qcow2 via the monitor, so it must be running.
if vm.state != .started && vm.state != .paused {
throw UTMSnapshotError.invalidVmState
// MARK: - Consistency with the backend
/// Find snapshots whose state is no longer in the backend, such as after another tool
/// modified it, and mark them in the manifest.
///
/// Call this when something that depends on the state of a snapshot fails.
static func identifyOrphanedSnapshots(on vm: any UTMVirtualMachine) async throws {
let backend = try backend(for: vm)
guard backend.isInventoryExact else {
return
}
snapshotLogger.debug("Deleting snapshot '\(name)' on QEMU VM")
try await vm.deleteSnapshot(name: name)
let entries = try await backend.entries()
try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in
for index in manifest.snapshots.indices {
let entry = entries.first { $0.identifier == manifest.snapshots[index].backendIdentifier }
manifest.snapshots[index].isOrphaned = entry == nil
// the state is there but does not cover every drive, which is not the same as gone
manifest.snapshots[index].isIncomplete = entry.map { !$0.isComplete } ?? false
}
manifest.forgetStaleStates(notIn: entries)
}
}
/// Add a snapshot for every state in the backend that none links to, such as those made by
/// another tool, and refresh what the backend knows about the others.
///
/// Anything the backend does not store is generated.
@discardableResult
private static func importUnlinkedSnapshots(from backend: any UTMSnapshotBackend, on vm: any UTMVirtualMachine) async throws -> UTMSnapshotManifest {
let entries = try await backend.entries().sorted { $0.date < $1.date }
return try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in
for index in manifest.snapshots.indices {
if let entry = entries.first(where: { $0.identifier == manifest.snapshots[index].backendIdentifier }) {
manifest.snapshots[index].size = entry.size
manifest.snapshots[index].hasState = entry.hasState
manifest.snapshots[index].isOrphaned = false
manifest.snapshots[index].isIncomplete = !entry.isComplete
}
}
manifest.forgetStaleStates(notIn: entries)
for entry in entries where !manifest.isReferenced(backendIdentifier: entry.identifier) {
guard entry.identifier != backend.defaultSuspendIdentifier && !manifest.staleIdentifiers.contains(entry.identifier) else {
// only ever used for suspending, or waiting to be deleted
continue
}
logger.debug("Importing snapshot '\(entry.identifier)'")
// an identifier that is not generated was chosen by someone
let title = UUID(uuidString: entry.identifier) == nil ? manifest.uniqueTitle(entry.identifier) : manifest.defaultTitle
let parent = manifest.snapshots.first { $0.backendIdentifier == entry.parentIdentifier }
var snapshot = Entry(title: title,
parentID: parent?.id,
date: entry.date,
size: entry.size,
hasState: entry.hasState,
backendIdentifier: entry.identifier)
snapshot.isIncomplete = !entry.isComplete
manifest.insert(snapshot)
}
manifest.isBackendImported = true
return manifest
}
}
// MARK: - Reconciling with the backend
/// Bring the manifest in line with what the backend holds.
///
/// Snapshots made by other tools are added and snapshots whose state has disappeared are
/// marked. The snapshots tab does this the first time a VM is opened; everywhere else it has
/// to be asked for, because it reads every image.
static func scanSnapshots(on vm: any UTMVirtualMachine) async throws {
let backend = try backend(for: vm)
guard backend.isInventoryExact else {
throw UTMSnapshotError.requiresStoppedVm
}
try await importUnlinkedSnapshots(from: backend, on: vm)
try await identifyOrphanedSnapshots(on: vm)
}
// MARK: - Helpers
private static func requireQemuBackend(_ vm: any UTMVirtualMachine) throws -> UTMQemuVirtualMachine {
guard let qemu = vm as? UTMQemuVirtualMachine else {
/// Pick the backend for the kind of VM and for whether it is running
private static func backend(for vm: any UTMVirtualMachine) throws -> any UTMSnapshotBackend {
guard !vm.isRunningAsDisposible else {
throw UTMSnapshotError.notSupported
}
return qemu
guard isSupported(for: vm) else {
throw UTMSnapshotError.notSupported
}
if let qemu = vm as? UTMQemuVirtualMachine {
switch vm.state {
case .started, .paused: return UTMQemuLiveSnapshotBackend(vm: qemu, imageURLs: qemu.snapshotImageURLs)
case .stopped: return UTMQemuFrozenSnapshotBackend(imageURLs: qemu.snapshotImageURLs, vm: qemu)
default: throw UTMSnapshotError.invalidVmState
}
}
#if os(macOS)
if #available(macOS 27, *), let apple = vm as? UTMAppleVirtualMachine {
guard [.started, .paused, .stopped].contains(vm.state) else {
throw UTMSnapshotError.invalidVmState
}
return UTMAppleSnapshotBackend(imageURLs: apple.snapshotImageURLs,
auxiliaryURLs: apple.snapshotAuxiliaryURLs,
savedStateURL: apple.config.system.boot.vmSavedStateURL,
isRunning: vm.state != .stopped,
vm: apple)
}
#endif
throw UTMSnapshotError.notSupported
}
private static func validate(name: String) throws {
if name.caseInsensitiveCompare(reservedSuspendName) == .orderedSame {
throw UTMSnapshotError.reservedName(name)
/// State the VM is suspended to, if it is
private static func suspendIdentifier(of vm: any UTMVirtualMachine, in backend: any UTMSnapshotBackend) throws -> String? {
guard vm.registryEntry.isSuspended else {
return nil
}
let pattern = "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$"
guard name.range(of: pattern, options: .regularExpression) != nil else {
throw UTMSnapshotError.invalidName(name)
return try UTMSnapshotManifest.load(from: vm.pathUrl)?.suspendIdentifier ?? backend.defaultSuspendIdentifier
}
/// Delete states that nothing needs anymore. Each stays listed in the manifest until it is
/// gone so that one that fails, such as one a running VM cannot delete, is tried again the
/// next time instead of staying in the backend for good.
private static func deleteStaleStates(_ identifiers: [String], from backend: any UTMSnapshotBackend, on vm: any UTMVirtualMachine) async {
for identifier in identifiers {
do {
try await backend.delete(identifier: identifier)
logger.debug("Deleted stale state '\(identifier)'")
} catch UTMSnapshotError.notFound(_) {
// already gone
} catch {
logger.debug("Keeping stale state '\(identifier)': \(error)")
continue
}
try? UTMSnapshotManifest.forgetState(identifier, in: vm.pathUrl)
}
}
/// Keep the most recent screenshot of the VM for a snapshot.
/// - Returns: Name of the file in the screenshots directory
private static func saveScreenshot(of vm: any UTMVirtualMachine) -> String? {
let fileManager = FileManager.default
let screenshotsURL = UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl)
let name = UUID().uuidString + ".png"
if vm.state != .stopped {
try? vm.saveScreenshot()
}
do {
try fileManager.createDirectory(at: screenshotsURL, withIntermediateDirectories: true)
try fileManager.copyItem(at: vm.pathUrl.appendingPathComponent(kUTMBundleScreenshotFilename), to: screenshotsURL.appendingPathComponent(name))
return name
} catch {
return nil
}
}
/// Show what a snapshot shows for the current state of a VM.
///
/// A snapshot taken while the screen was not being kept has no picture of its own, and then
/// the one on show belongs to a state the VM is no longer in, so it goes.
private static func restoreScreenshot(of snapshot: Entry, on vm: any UTMVirtualMachine) {
let fileManager = FileManager.default
let currentURL = vm.pathUrl.appendingPathComponent(kUTMBundleScreenshotFilename)
try? fileManager.removeItem(at: currentURL)
if let screenshotURL = screenshotURL(for: snapshot, on: vm), fileManager.fileExists(atPath: screenshotURL.path) {
try? fileManager.copyItem(at: screenshotURL, to: currentURL)
}
try? vm.reloadScreenshotFromFile()
}
}
// MARK: - Read model
/// A snapshot of a VM as it is shown and scripted
struct UTMSnapshot: Identifiable, Equatable {
let id: UUID
/// User-editable display name
var title: String
/// Snapshot the current state was based on when this one was created, `nil` if there was none
var parentID: UUID?
var dateCreated: Date
/// Last time the snapshot was renamed or overwritten
var dateModified: Date
/// Bytes occupied in the backend
var size: Int64
/// Includes the running state of the VM, otherwise it starts up from the saved disks
var hasState: Bool
/// The saved state is missing so the snapshot can only be deleted
var isOrphaned: Bool
/// The saved state does not cover every drive so the snapshot cannot be restored
var isIncomplete: Bool
/// Screen contents when the snapshot was taken, if one was captured
var screenshotURL: URL?
@MainActor
fileprivate init(_ entry: UTMSnapshotManifest.Snapshot, on vm: any UTMVirtualMachine) {
id = entry.id
title = entry.title
parentID = entry.parentID
dateCreated = entry.dateCreated
dateModified = entry.dateModified
size = entry.size
hasState = entry.hasState
isOrphaned = entry.isOrphaned
isIncomplete = entry.isIncomplete
screenshotURL = UTMSnapshotService.screenshotURL(for: entry, on: vm)
}
}
/// The snapshots of a VM along with the current state they are listed under
struct UTMSnapshotTimeline: Equatable {
/// Newest first
var snapshots: [UTMSnapshot]
/// Snapshot the current state descends from, `nil` if there is none
var currentParentID: UUID?
/// Nothing changed since the snapshot the current state is based on
var isCurrentStateSaved: Bool
}
private extension UTMSnapshotManifest {
/// Stop resuming from `previous`, the state the VM was found suspended to, which the manifest
/// may not name when it was suspended by an older version
mutating func replaceSuspendState(_ previous: String?, with backendIdentifier: String?) {
if suspendIdentifier == nil {
suspendIdentifier = previous
}
setSuspendState(backendIdentifier)
}
/// States that were to be deleted and are gone from an exact inventory need no deleting
mutating func forgetStaleStates(notIn entries: [UTMSnapshotBackendEntry]) {
staleIdentifiers.removeAll { identifier in !entries.contains { $0.identifier == identifier } }
}
var defaultTitle: String {
var number = snapshots.count + 1
var title: String
repeat {
title = String.localizedStringWithFormat(NSLocalizedString("Snapshot %lld", comment: "UTMSnapshotService"), number)
number += 1
} while snapshots.contains { $0.title == title }
return title
}
/// - Parameter id: Snapshot that may keep the title it already has
func uniqueTitle(_ base: String, excluding id: UUID? = nil) -> String {
var title = base
var number = 2
while snapshots.contains(where: { $0.title == title && $0.id != id }) {
title = "\(base) \(number)"
number += 1
}
return title
}
}
enum UTMSnapshotError: Error {
case reservedName(String)
case invalidName(String)
case notFound(String)
case orphaned(String)
case incomplete(String)
case noDrives
case notSupported
case invalidVmState
case listRequiresStoppedVm
case restoreRequiresStoppedVm
case requiresStoppedVm
}
extension UTMSnapshotError: LocalizedError {
var errorDescription: String? {
switch self {
case .reservedName(let name):
return String.localizedStringWithFormat(NSLocalizedString("The name '%@' is reserved and cannot be used for a snapshot.", comment: "UTMSnapshotService"), name)
case .invalidName(let name):
return String.localizedStringWithFormat(NSLocalizedString("The name '%@' is not a valid snapshot name. Use letters, numbers, and the characters _.- (up to 64 characters).", comment: "UTMSnapshotService"), name)
case .notFound(let name):
return String.localizedStringWithFormat(NSLocalizedString("The snapshot '%@' does not exist.", comment: "UTMSnapshotService"), name)
case .orphaned(let name):
return String.localizedStringWithFormat(NSLocalizedString("The data of the snapshot '%@' is missing. It may have been removed by another application.", comment: "UTMSnapshotService"), name)
case .incomplete(let name):
return String.localizedStringWithFormat(NSLocalizedString("The snapshot '%@' cannot be restored because it does not cover every drive of the virtual machine, such as a drive added after it was taken.", comment: "UTMSnapshotService"), name)
case .noDrives:
return NSLocalizedString("A snapshot needs a drive that the virtual machine can write to.", comment: "UTMSnapshotService")
case .notSupported:
return NSLocalizedString("Snapshots are not supported for this virtual machine.", comment: "UTMSnapshotService")
case .invalidVmState:
return NSLocalizedString("The virtual machine is in an invalid state for this snapshot operation.", comment: "UTMSnapshotService")
case .listRequiresStoppedVm:
return NSLocalizedString("The virtual machine must be stopped before listing snapshots.", comment: "UTMSnapshotService")
case .restoreRequiresStoppedVm:
return NSLocalizedString("The virtual machine must be stopped before restoring a snapshot.", comment: "UTMSnapshotService")
case .requiresStoppedVm:
return NSLocalizedString("The virtual machine must be stopped for this snapshot operation.", comment: "UTMSnapshotService")
}
}
}
+48
View File
@@ -140,10 +140,19 @@
8453DCB2278CE3D10037A0DA /* qemu-img.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = 8453DCB0278CE33E0037A0DA /* qemu-img.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
8453DCB4278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
393A92DAE84A47029BA5F829 /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
738DDF2A88F46375A0D72177 /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; };
EFAB3C24B8C39BC85EEFE028 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; };
AFC3FBE9C92083C52D800437 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; };
8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
8F8F6CA6377BBED5737019BB /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; };
12618B76026270B5C1C8CD29 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; };
BCB19D49AC1352354193ACB6 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; };
8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
63353664DA55B4F6AC8BD07B /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; };
6E14FA50A12736E32EA02200 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; };
E1B70310A596175A0E9F3025 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; };
845F1705289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1704289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift */; };
845F1707289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */; };
845F1709289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1708289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift */; };
@@ -168,8 +177,11 @@
8471772927CD3CAB00D3A50B /* DetailedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471772727CD3CAB00D3A50B /* DetailedSection.swift */; };
8471772A27CD3CAB00D3A50B /* DetailedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471772727CD3CAB00D3A50B /* DetailedSection.swift */; };
847BF9AA2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; };
29B2D623BB879D2653CA8C53 /* VMSnapshotData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */; };
847BF9AB2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; };
10488F379FF913D2E7770E60 /* VMSnapshotData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */; };
847BF9AC2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; };
A5A9A26C444286951EC315D9 /* VMSnapshotData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */; };
84818C0C2898A07A009EDB67 /* AVFAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84818C0B2898A07A009EDB67 /* AVFAudio.framework */; };
84818C0D2898A07F009EDB67 /* AVFAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84818C0B2898A07A009EDB67 /* AVFAudio.framework */; };
848308D5278A1F2200E3E474 /* Virtualization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 848308D4278A1F2200E3E474 /* Virtualization.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
@@ -615,7 +627,9 @@
CE2D956A24AD4F990059923A /* VMPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954224AD4F980059923A /* VMPlaceholderView.swift */; };
CE2D956C24AD4F990059923A /* VMCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954324AD4F980059923A /* VMCardView.swift */; };
CE2D956F24AD4F990059923A /* VMRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */; };
F3B87AB8E7FA8B9648FC0EAD /* VMSnapshotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */; };
CE2D957024AD4F990059923A /* VMRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */; };
D38CFF45AB0566027A30677F /* VMSnapshotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */; };
CE2D957124AD4F990059923A /* UTMExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954624AD4F980059923A /* UTMExtensions.swift */; };
CE2D957224AD4F990059923A /* UTMExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954624AD4F980059923A /* UTMExtensions.swift */; };
CE2D957324AD4F990059923A /* VMConfigSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954724AD4F980059923A /* VMConfigSharingView.swift */; };
@@ -673,6 +687,7 @@
CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */; };
CE7A1C062EFA0A01001671E9 /* AccessoryAccess.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE7A1C052EFA0A01001671E9 /* AccessoryAccess.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */; };
1C5910D866A20395225768B9 /* UTMAppleSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */; };
CE1D15AD2F6B100100D15C01 /* DiskImageKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE1D15AC2F6B100100D15C01 /* DiskImageKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
CE6804852E4E5D84001671E9 /* UTMScriptingInputImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */; };
CE68E5442E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; };
@@ -752,6 +767,7 @@
CEA3EE05301691C500AAA8E4 /* QEMURenderServer.app in Embed Executables */ = {isa = PBXBuildFile; fileRef = CEA3EDFA30168B9000AAA8E4 /* QEMURenderServer.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
CEA45E25263519B5002FA97D /* VMDisplayMetalViewController+Pointer.h in Sources */ = {isa = PBXBuildFile; fileRef = 83FBDD53242FA71900D2C5D7 /* VMDisplayMetalViewController+Pointer.h */; };
CEA45E27263519B5002FA97D /* VMRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */; };
E427638537FF5935C6DCAF11 /* VMSnapshotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */; };
CEA45E37263519B5002FA97D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE772AAB25C8B0F600E4E379 /* ContentView.swift */; };
CEA45E3A263519B5002FA97D /* UTMLegacyQemuConfiguration+System.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5425332437C22A00E520F7 /* UTMLegacyQemuConfiguration+System.m */; };
CEA45E3F263519B5002FA97D /* UTMProcess.m in Sources */ = {isa = PBXBuildFile; fileRef = CE9D197B226542FE00355E14 /* UTMProcess.m */; };
@@ -958,6 +974,7 @@
CEC1B00C2BBB211C0088119D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */; };
CEC1B00D2BBB211C0088119D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */; };
CEC794BA294924E300121A9F /* UTMScriptingSerialPortImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */; };
FC6D2EA0328DA5CD033B9518 /* UTMScriptingSnapshotImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */; };
CEC794BC2949663C00121A9F /* UTMScripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794BB2949663C00121A9F /* UTMScripting.swift */; };
CEC794BD2949663C00121A9F /* UTMScripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794BB2949663C00121A9F /* UTMScripting.swift */; };
CED234ED254796E500ED0A57 /* NumberTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED234EC254796E500ED0A57 /* NumberTextField.swift */; };
@@ -1783,6 +1800,9 @@
8453DCB0278CE33E0037A0DA /* qemu-img.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "qemu-img.framework"; path = "$(SYSROOT_DIR)/Frameworks/qemu-img.framework"; sourceTree = "<group>"; };
8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMQemuImage.swift; sourceTree = "<group>"; };
EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSnapshotService.swift; sourceTree = "<group>"; };
F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMQemuSnapshotBackend.swift; sourceTree = "<group>"; };
DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSnapshotBackend.swift; sourceTree = "<group>"; };
0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSnapshotManifest.swift; sourceTree = "<group>"; };
845F1704289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleConfigurationGenericPlatform.swift; sourceTree = "<group>"; };
845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMAppleSettingsAddDeviceMenuView.swift; sourceTree = "<group>"; };
845F1708289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayAppleTerminalWindowController.swift; sourceTree = "<group>"; };
@@ -1796,6 +1816,7 @@
8471770527CC974F00D3A50B /* DefaultTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTextField.swift; sourceTree = "<group>"; };
8471772727CD3CAB00D3A50B /* DetailedSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailedSection.swift; sourceTree = "<group>"; };
847BF9A92A49C783000BD9AA /* VMData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMData.swift; sourceTree = "<group>"; };
16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSnapshotData.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@@ -1987,6 +2008,7 @@
CE2D954224AD4F980059923A /* VMPlaceholderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMPlaceholderView.swift; sourceTree = "<group>"; };
CE2D954324AD4F980059923A /* VMCardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMCardView.swift; sourceTree = "<group>"; };
CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMRemovableDrivesView.swift; sourceTree = "<group>"; };
B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMSnapshotsView.swift; sourceTree = "<group>"; };
CE2D954624AD4F980059923A /* UTMExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UTMExtensions.swift; sourceTree = "<group>"; };
CE2D954724AD4F980059923A /* VMConfigSharingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMConfigSharingView.swift; sourceTree = "<group>"; };
CE2D954824AD4F980059923A /* VMConfigInputView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMConfigInputView.swift; sourceTree = "<group>"; };
@@ -2040,6 +2062,7 @@
CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleUSBManager.swift; sourceTree = "<group>"; };
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 = "<group>"; };
0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleSnapshotBackend.swift; sourceTree = "<group>"; };
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 = "<group>"; };
CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMKeyboardShortcutsView.swift; sourceTree = "<group>"; };
@@ -2145,6 +2168,7 @@
CEC0A3092A7490D200980857 /* VMConfigQEMUArgumentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigQEMUArgumentsView.swift; sourceTree = "<group>"; };
CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingSerialPortImpl.swift; sourceTree = "<group>"; };
38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingSnapshotImpl.swift; sourceTree = "<group>"; };
CEC794BB2949663C00121A9F /* UTMScripting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UTMScripting.swift; sourceTree = "<group>"; };
CEC9968328AA516000E7A025 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
CECF02562B706ADD00409FC0 /* UTMRemoteConnectInterface.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMRemoteConnectInterface.h; sourceTree = "<group>"; };
@@ -2786,6 +2810,7 @@
835AA7B026AB7C85007A0411 /* UTMPendingVirtualMachine.swift */,
CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */,
847BF9A92A49C783000BD9AA /* VMData.swift */,
16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */,
CE550BD52259479D0063E575 /* Assets.xcassets */,
521F3EFB2414F73800130500 /* Localizable.strings */,
CED8DF7928A120C100C34345 /* Localizable.stringsdict */,
@@ -2901,6 +2926,7 @@
848A98B5286A142C006F0550 /* UTMAppleConfigurationSerial.swift */,
848A98B3286A1215006F0550 /* UTMAppleConfigurationVirtualization.swift */,
841619A5284315C1000034B2 /* UTMQemuConfiguration.swift */,
0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */,
848D99C328670F650055C215 /* UTMQemuConfiguration+Arguments.swift */,
843BF82328441EAD0029D60D /* UTMQemuConfigurationDisplay.swift */,
8443EFF12845641600B2E6E2 /* UTMQemuConfigurationDrive.swift */,
@@ -2983,6 +3009,8 @@
CE9D197B226542FE00355E14 /* UTMProcess.m */,
8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */,
EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */,
F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */,
DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */,
84A0A8822A47D52E0038F329 /* UTMQemuPort.swift */,
CE03D05424D90BE000F76B84 /* UTMQemuSystem.h */,
CE03D05024D90B4E00F76B84 /* UTMQemuSystem.m */,
@@ -2999,6 +3027,7 @@
CE7A1C012EFA0A01001671E9 /* UTMAppleUSBDevice.swift */,
CE7A1C032EFA0A01001671E9 /* UTMAppleUSBManager.swift */,
CE1D15AA2F6B100100D15C01 /* UTMAppleDiskImage.swift */,
0CBB52BFA0D55FDEC6497878 /* UTMAppleSnapshotBackend.swift */,
CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */,
CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */,
4F9F544E438D4BE8A5CA9360 /* UTMAppleVmnetNetworkManager.swift */,
@@ -3143,6 +3172,7 @@
8432328F28C2CDAD00CFBC97 /* VMNavigationListView.swift */,
CE2D954224AD4F980059923A /* VMPlaceholderView.swift */,
CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */,
B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */,
84C4D9012880CA8A00EC3B2B /* VMSettingsAddDeviceMenuView.swift */,
CE8813D424CD265700532628 /* VMShareFileModifier.swift */,
CE2D953824AD4F980059923A /* VMToolbarModifier.swift */,
@@ -3197,6 +3227,7 @@
CE59A7AF2AA69AE400E5FFBD /* UTMScriptingUSBDeviceImpl.swift */,
CEFE98E029485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift */,
CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */,
38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */,
CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */,
CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */,
CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */,
@@ -3807,10 +3838,12 @@
CE231D462BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */,
84C4D9022880CA8A00EC3B2B /* VMSettingsAddDeviceMenuView.swift in Sources */,
CE2D956F24AD4F990059923A /* VMRemovableDrivesView.swift in Sources */,
F3B87AB8E7FA8B9648FC0EAD /* VMSnapshotsView.swift in Sources */,
8443EFF22845641600B2E6E2 /* UTMQemuConfigurationDrive.swift in Sources */,
8443EFFA28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */,
CE772AAC25C8B0F600E4E379 /* ContentView.swift in Sources */,
847BF9AA2A49C783000BD9AA /* VMData.swift in Sources */,
29B2D623BB879D2653CA8C53 /* VMSnapshotData.swift in Sources */,
CE2D927A24AD46670059923A /* UTMLegacyQemuConfiguration+System.m in Sources */,
843BF8302844853E0029D60D /* UTMQemuConfigurationNetwork.swift in Sources */,
CE2D927C24AD46670059923A /* UTMProcess.m in Sources */,
@@ -3899,6 +3932,9 @@
848D99A8285DB5550055C215 /* VMConfigConstantPicker.swift in Sources */,
8453DCB4278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
393A92DAE84A47029BA5F829 /* UTMSnapshotService.swift in Sources */,
738DDF2A88F46375A0D72177 /* UTMQemuSnapshotBackend.swift in Sources */,
EFAB3C24B8C39BC85EEFE028 /* UTMSnapshotBackend.swift in Sources */,
AFC3FBE9C92083C52D800437 /* UTMSnapshotManifest.swift in Sources */,
CE2D955924AD4F980059923A /* VMToolbarModifier.swift in Sources */,
CE2D92DA24AD46670059923A /* VMCursor.m in Sources */,
CE9375A124BBDDD10074066F /* VMConfigDriveDetailsView.swift in Sources */,
@@ -4031,6 +4067,7 @@
CE9375A224BBDDD10074066F /* VMConfigDriveDetailsView.swift in Sources */,
CE2D955824AD4F980059923A /* VMConfigDisplayView.swift in Sources */,
CEC794BA294924E300121A9F /* UTMScriptingSerialPortImpl.swift in Sources */,
FC6D2EA0328DA5CD033B9518 /* UTMScriptingSnapshotImpl.swift in Sources */,
CE03D05324D90B4E00F76B84 /* UTMQemuSystem.m in Sources */,
CE88A1602E24B2B400EAA28E /* UTMInputIntent.swift in Sources */,
841619B82843226B000034B2 /* QEMUConstant.swift in Sources */,
@@ -4135,6 +4172,7 @@
CEFE96772B69A7CC000F00C9 /* VMRemoteSessionState.swift in Sources */,
CEEF26A72CEDAEEA003F7B8C /* UTMDownloadMacSupportToolsTask.swift in Sources */,
CE2D957024AD4F990059923A /* VMRemovableDrivesView.swift in Sources */,
D38CFF45AB0566027A30677F /* VMSnapshotsView.swift in Sources */,
CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */,
CE0B6CFE24AD56AE00FE012D /* UTMLogging.m in Sources */,
843BF82E284482C10029D60D /* UTMQemuConfigurationInput.swift in Sources */,
@@ -4145,6 +4183,9 @@
CE2D956224AD4F990059923A /* VMSettingsView.swift in Sources */,
8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */,
63353664DA55B4F6AC8BD07B /* UTMQemuSnapshotBackend.swift in Sources */,
6E14FA50A12736E32EA02200 /* UTMSnapshotBackend.swift in Sources */,
E1B70310A596175A0E9F3025 /* UTMSnapshotManifest.swift in Sources */,
CEDF83FA258AE24E0030E4AC /* UTMPasteboard.swift in Sources */,
848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */,
848A98BA286A17A8006F0550 /* UTMAppleConfigurationNetwork.swift in Sources */,
@@ -4173,6 +4214,7 @@
CE7A1C022EFA0A01001671E9 /* UTMAppleUSBDevice.swift in Sources */,
CE7A1C042EFA0A01001671E9 /* UTMAppleUSBManager.swift in Sources */,
CE1D15AB2F6B100100D15C01 /* UTMAppleDiskImage.swift in Sources */,
1C5910D866A20395225768B9 /* UTMAppleSnapshotBackend.swift in Sources */,
CEF0306F26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */,
83034C0926AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,
@@ -4182,6 +4224,7 @@
CE9B153F2B11A63E003A32DD /* UTMRemoteServer.swift in Sources */,
846F8D5A2E3891FE0037162B /* UTMKeyboardShortcuts.swift in Sources */,
847BF9AC2A49C783000BD9AA /* VMData.swift in Sources */,
A5A9A26C444286951EC315D9 /* VMSnapshotData.swift in Sources */,
CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */,
CE2D958824AD4F990059923A /* VMConfigPortForwardForm.swift in Sources */,
03FA9C752B9BBDB000C53A5A /* UTMConfigurationHostNetwork.swift in Sources */,
@@ -4207,6 +4250,7 @@
CEA45E25263519B5002FA97D /* VMDisplayMetalViewController+Pointer.h in Sources */,
84C505AD28C588EC007CE8FF /* SizeTextField.swift in Sources */,
CEA45E27263519B5002FA97D /* VMRemovableDrivesView.swift in Sources */,
E427638537FF5935C6DCAF11 /* VMSnapshotsView.swift in Sources */,
CEF0306E26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */,
CE231D432BDDF280006D6DC3 /* UTMDonateView.swift in Sources */,
CE65BABF26A4D8DD0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */,
@@ -4287,6 +4331,9 @@
848A98C5286F332D006F0550 /* UTMConfiguration.swift in Sources */,
8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */,
8F8F6CA6377BBED5737019BB /* UTMQemuSnapshotBackend.swift in Sources */,
12618B76026270B5C1C8CD29 /* UTMSnapshotBackend.swift in Sources */,
BCB19D49AC1352354193ACB6 /* UTMSnapshotManifest.swift in Sources */,
84C60FBB268269D700B58C00 /* VMDisplayViewController.swift in Sources */,
8443EFFB28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */,
848D99BD28636AC90055C215 /* UTMConfigurationDrive.swift in Sources */,
@@ -4345,6 +4392,7 @@
CEF0304F26A2AFBF00667B63 /* BigButtonStyle.swift in Sources */,
84018691288A73300050AC51 /* VMDisplayViewController.m in Sources */,
847BF9AB2A49C783000BD9AA /* VMData.swift in Sources */,
10488F379FF913D2E7770E60 /* VMSnapshotData.swift in Sources */,
84909A8E27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,
CEA45EF4263519B5002FA97D /* VMConfigSoundView.swift in Sources */,
8432329928C3017F00CFBC97 /* GlobalFileImporter.swift in Sources */,
@@ -52,7 +52,7 @@
"location" : "https://github.com/utmapp/QEMUKit.git",
"state" : {
"branch" : "main",
"revision" : "589765abff27a8764d58b1a90999a204ac09881e"
"revision" : "14f445a7333edc7113f9e37f6d2915b1c5ca00b8"
}
},
{
+169 -33
View File
@@ -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<AppleEvent>, 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!()
}
}
}