mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
Merge pull request #7896 from utmapp/feature/snapshots-gui
snapshots: manage snapshots of QEMU and Apple VMs
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, *)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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!()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user