snapshots: manage QEMU snapshots through a backend

Snapshots were read straight out of the QEMU images by the scripting
commands, which only worked while the VM was off and knew nothing beyond
the tags in the image.

Put a backend behind the operations instead. Which one is chosen depends on
whether the VM is running, and each says what it can do in that state:

- A running VM goes through the QEMU monitor, which saves and loads the
  whole machine along with the disks.
- A VM that is off goes through qemu-img, which can only save and revert
  disk contents. Nothing may touch the images while QEMU has them open, so
  a snapshot of disks alone is left alone until the VM stops.

Either way a snapshot spans every writable image, the UEFI variable store
included, because QEMU refuses to load one that any of them is missing. A
snapshot that some of them lack, such as one taken before a drive was added,
is kept but cannot be restored, which is not the same as its state being
gone: reverting only some of the drives would leave them at different points
in time. Reclaiming space rebuilds an image without its snapshots, so it is
refused while there are any.

UTMSnapshotService sits above the backend and keeps the manifest and the
stored state consistent: it marks entries whose state has disappeared when
an operation fails, and the first time a VM is opened it adopts whatever
snapshots are already in the images so that snapshots taken by other tools
are not lost. Both wait until the VM is stopped, because a running QEMU
lists the snapshots of every drive together, removable ones included.

Suspending is folded into the same mechanism. A VM now suspends to a
snapshot of its own rather than always the same name, so a suspended state
can be kept as a snapshot without copying it, and restoring a snapshot
makes the VM resume from it. The state is deleted once nothing refers to
it, where it used to be left behind in the image. One that cannot be deleted
right away stays listed in the manifest and is tried again the next time.

Assisted-by: Claude:claude-fable-5-1
Assisted-by: Claude:claude-opus-5
This commit is contained in:
osy
2026-09-22 10:57:30 -05:00
parent 9e8bd13e32
commit 0086417b97
9 changed files with 906 additions and 132 deletions
@@ -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 {
+7
View File
@@ -911,6 +911,10 @@ enum AlertItem: Identifiable {
/// - Parameter driveUrl: Original drive to convert
/// - Parameter isCompressed: Compress existing data
func reclaimSpace(for driveUrl: URL, withCompression isCompressed: Bool = false) async throws {
// converting leaves the snapshots in the image behind, and a suspended state is one of them
guard try await UTMQemuImage.snapshots(image: driveUrl).isEmpty else {
throw UTMDataError.driveHasSnapshots
}
let baseUrl = driveUrl.deletingLastPathComponent()
let dstUrl = Self.newImage(from: driveUrl, to: baseUrl, withExtension: "qcow2")
defer {
@@ -1195,6 +1199,7 @@ enum UTMDataError: Error {
case jitStreamerUrlInvalid(String)
case notImplemented
case reconnectFailed
case driveHasSnapshots
}
extension UTMDataError: LocalizedError {
@@ -1228,6 +1233,8 @@ extension UTMDataError: LocalizedError {
return NSLocalizedString("This functionality is not yet implemented.", comment: "UTMData")
case .reconnectFailed:
return NSLocalizedString("Failed to reconnect to the server.", comment: "UTMData")
case .driveHasSnapshots:
return NSLocalizedString("Space cannot be reclaimed from a drive while it holds snapshots or a suspended state.", comment: "UTMData")
}
}
}
@@ -299,8 +299,7 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
@objc func listSnapshots(_ command: NSScriptCommand) {
withScriptCommand(command) { [self] in
let entries = try await UTMSnapshotService.listSnapshots(on: vm)
return entries.map { $0.name }
return try await UTMSnapshotService.listSnapshots(on: vm)
}
}
+210
View File
@@ -0,0 +1,210 @@
//
// Copyright © 2026 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import QEMUKitInternal
/// Snapshot tag that a VM was always suspended to before suspend states could be kept as snapshots
let kUTMQemuDefaultSuspendSnapshotName = "suspend"
extension UTMQemuVirtualMachine {
/// Images that take part in a snapshot
@MainActor var snapshotImageURLs: [URL] {
var imageURLs = config.drives.compactMap { drive -> URL? in
guard drive.imageType == .disk && !drive.isExternal && !drive.isReadOnly else {
return nil
}
return drive.imageURL
}
// the same condition under which QEMU is given the variable store
if config.qemu.hasUefiBoot, !config.hasCustomBios, let efiVarsURL = config.qemu.efiVarsURL, FileManager.default.fileExists(atPath: efiVarsURL.path) {
imageURLs.insert(efiVarsURL, at: 0)
}
return imageURLs
}
}
// MARK: - Live
/// Internal snapshots of a running QEMU, managed through the monitor.
///
/// QEMU has the images open so nothing else may touch them, least of all qemu-img. It saves and
/// loads the running state along with the disks. A snapshot of just the disks cannot be loaded
/// into a running guest and is left alone until the VM is off.
struct UTMQemuLiveSnapshotBackend: UTMSnapshotBackend {
let vm: UTMQemuVirtualMachine
let imageURLs: [URL]
/// QEMU lists the snapshots of every drive together, removable ones included
let isInventoryExact = false
let defaultSuspendIdentifier = kUTMQemuDefaultSuspendSnapshotName
func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool {
operation == .create || hasState
}
func entries() async throws -> [UTMSnapshotBackendEntry] {
guard let monitor = await vm.monitor else {
throw UTMSnapshotError.invalidVmState
}
return try monitor.snapshots().map { snapshot in
UTMSnapshotBackendEntry(identifier: snapshot.name,
date: snapshot.date,
size: snapshot.vmStateSize,
hasState: snapshot.vmStateSize > 0)
}
}
func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry {
// the state is saved anew because a VM can keep running after it was suspended
let identifier = UUID().uuidString
try await vm.saveSnapshot(name: identifier)
guard let entry = try await entries().first(where: { $0.identifier == identifier }) else {
throw UTMSnapshotError.notFound(identifier)
}
return entry
}
func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool {
try await vm.restoreSnapshot(name: entry.identifier)
return false
}
func delete(identifier: String) async throws {
try await vm.deleteSnapshot(name: identifier)
}
}
// MARK: - Frozen
/// Internal snapshots in the images of a QEMU that is not running, managed through qemu-img.
///
/// A snapshot spans every image, including the one for the UEFI variables. QEMU saves and loads
/// all of them together and refuses to load a snapshot that any of them is missing.
struct UTMQemuFrozenSnapshotBackend: UTMSnapshotBackend {
let imageURLs: [URL]
/// Kept from starting while the images change, unless the caller already sees to that
var vm: UTMQemuVirtualMachine? = nil
let isInventoryExact = true
let defaultSuspendIdentifier = kUTMQemuDefaultSuspendSnapshotName
func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool {
true
}
func entries() async throws -> [UTMSnapshotBackendEntry] {
var entries = [String: UTMSnapshotBackendEntry]()
var imageCounts = [String: Int]()
for imageURL in imageURLs {
for snapshot in try await UTMQemuImage.snapshots(image: imageURL) {
// the VM state is only in one of the images
let previous = entries[snapshot.name]
entries[snapshot.name] = UTMSnapshotBackendEntry(identifier: snapshot.name,
date: max(snapshot.date, previous?.date ?? .distantPast),
size: snapshot.vmStateSize + (previous?.size ?? 0),
hasState: snapshot.vmStateSize > 0 || previous?.hasState == true)
imageCounts[snapshot.name, default: 0] += 1
}
}
return entries.values.map { entry in
var entry = entry
entry.isComplete = imageCounts[entry.identifier] == imageURLs.count
return entry
}
}
func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry {
if let suspendIdentifier = suspendIdentifier {
// nothing changed since the VM was suspended
guard let entry = try await entries().first(where: { $0.identifier == suspendIdentifier }), entry.isComplete else {
throw UTMSnapshotError.notFound(suspendIdentifier)
}
return entry
}
for imageURL in imageURLs {
guard try await UTMQemuImage.info(image: imageURL).format == "qcow2" else {
throw UTMSnapshotError.notSupported
}
}
let identifier = UUID().uuidString
try await changingImages(as: .saving) {
do {
for imageURL in imageURLs {
try await UTMQemuImage.createSnapshot(image: imageURL, name: identifier)
}
} catch {
try? await deleteFromImages(identifier: identifier)
throw error
}
}
guard let entry = try await entries().first(where: { $0.identifier == identifier }) else {
throw UTMSnapshotError.notFound(identifier)
}
return entry
}
func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool {
guard entry.isComplete else {
throw UTMSnapshotError.incomplete(entry.identifier)
}
// the disks are reverted now even when QEMU loads the running state on the next start,
// so they hold the snapshot should that state be discarded instead
try await changingImages(as: .restoring) {
for imageURL in imageURLs {
try await UTMQemuImage.applySnapshot(image: imageURL, name: entry.identifier)
}
}
return entry.hasState
}
func delete(identifier: String) async throws {
try await changingImages(as: .saving) {
try await deleteFromImages(identifier: identifier)
}
}
private func deleteFromImages(identifier: String) async throws {
for imageURL in try await imageURLs(containing: identifier) {
try await UTMQemuImage.deleteSnapshot(image: imageURL, name: identifier)
}
}
private func changingImages(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws {
if let vm = vm {
try await vm.changingImages(as: state, body)
} else {
try await body()
}
}
private func imageURLs(containing identifier: String) async throws -> [URL] {
var found = [URL]()
for imageURL in imageURLs {
if try await UTMQemuImage.snapshots(image: imageURL).contains(where: { $0.name == identifier }) {
found.append(imageURL)
}
}
guard !found.isEmpty else {
throw UTMSnapshotError.notFound(identifier)
}
return found
}
}
+80 -39
View File
@@ -21,7 +21,6 @@ import SwiftPortmap
#endif
private var SpiceIoServiceGuestAgentContext = 0
private let kSuspendSnapshotName = "suspend"
private let kProbeSuspendDelay = 1*NSEC_PER_SEC
/// QEMU backend virtual machine
@@ -103,8 +102,6 @@ final class UTMQemuVirtualMachine: UTMSpiceVirtualMachine {
private(set) var snapshotUnsupportedError: Error?
/// When set, the next `_start` will load this snapshot tag instead of a fresh/suspended boot.
private var pendingRestoreSnapshotName: String?
private var isScopedAccess: Bool = false
@@ -431,28 +428,21 @@ extension UTMQemuVirtualMachine {
try Task.checkCancellation()
// load saved state if requested
//
// A user snapshot restore on a stopped VM (`pendingRestoreSnapshotName`) reuses this
// startup path: boot paused, loadvm the requested tag, then continue. Once the VM is
// running, any previous internal suspend state is obsolete and is removed below.
let isSuspended = await registryEntry.isSuspended
let userRestoreSnapshotName = pendingRestoreSnapshotName
pendingRestoreSnapshotName = nil
let withMounting: Bool
if let userRestoreSnapshotName = userRestoreSnapshotName {
try await _restoreSnapshot(name: userRestoreSnapshotName)
try Task.checkCancellation()
withMounting = false
} else {
if !isRunningAsDisposible && isSuspended {
try await monitor.qemuRestoreSnapshot(kSuspendSnapshotName)
var isStateMissing = false
if !isRunningAsDisposible && isSuspended {
let suspendSnapshotName = suspendSnapshotName
if try monitor.snapshots().contains(where: { $0.name == suspendSnapshotName }) {
try await _restoreSnapshot(name: suspendSnapshotName)
try Task.checkCancellation()
} else {
// a state that is gone leaves the guest to boot fresh, as nothing of it was loaded
isStateMissing = true
}
withMounting = !isSuspended
}
// set up SPICE sharing and removable drives
try await self.restoreExternalDrives(withMounting: withMounting)
try await self.restoreExternalDrives(withMounting: !isSuspended || isStateMissing)
if let ioService = interface as? UTMSpiceIO {
try await self.restoreSharedDirectory(for: ioService)
} else {
@@ -463,7 +453,7 @@ extension UTMQemuVirtualMachine {
// continue VM boot
try await monitor.continueBoot()
// delete saved suspend state (user snapshots are preserved)
// delete saved suspend state
if isSuspended {
try? await deleteSnapshot()
}
@@ -622,31 +612,94 @@ extension UTMQemuVirtualMachine {
state = prev
}
do {
try await _saveSnapshot(name: name ?? kSuspendSnapshotName)
if name == nil {
if let name = name {
try await _saveSnapshot(name: name)
} else {
// a new name each time lets the previous one be kept as a snapshot
let suspendName = UUID().uuidString
try await _saveSnapshot(name: suspendName)
let staleNames = try UTMSnapshotManifest.replaceSuspendState(in: pathUrl, with: suspendName)
await registryEntry.setIsSuspended(true)
try saveScreenshot()
// a state that cannot go now stays listed and goes the next time
try? await deleteStaleSnapshots(staleNames)
}
} catch {
throw UTMQemuVirtualMachineError.saveSnapshotFailed(error)
}
}
/// Keep the VM from starting while qemu-img changes its images, which corrupts any that QEMU
/// has open. It may have them open without a monitor, so the VM has to be stopped.
func changingImages(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws {
guard self.state == .stopped else {
throw UTMQemuVirtualMachineError.invalidVmState
}
self.state = state
defer {
self.state = .stopped
}
try await body()
}
/// Snapshot with the state to resume from, older versions always used the same one
private var suspendSnapshotName: String {
UTMSnapshotManifest.suspendState(in: pathUrl) ?? kUTMQemuDefaultSuspendSnapshotName
}
private func _deleteSnapshot(name: String) async throws {
if let monitor = await monitor { // if QEMU is running
// QEMU only reports a missing snapshot as a failure, which would leave it listed
guard try monitor.snapshots().contains(where: { $0.name == name }) else {
throw UTMSnapshotError.notFound(name)
}
let result = try await monitor.qemuDeleteSnapshot(name)
if result.localizedCaseInsensitiveContains("Error") {
throw UTMQemuVirtualMachineError.qemuError(result)
}
try? updateLastModified()
} else {
try await changingImages(as: .saving) {
try await UTMQemuFrozenSnapshotBackend(imageURLs: await snapshotImageURLs).delete(identifier: name)
}
}
try? updateLastModified()
}
func deleteSnapshot(name: String? = nil) async throws {
if name == nil {
await registryEntry.setIsSuspended(false)
if let name = name {
try await _deleteSnapshot(name: name)
return
}
await registryEntry.setIsSuspended(false)
// the state stays if it was kept as a snapshot
let staleNames = try UTMSnapshotManifest.releaseSuspendState(in: pathUrl, default: kUTMQemuDefaultSuspendSnapshotName)
#if !os(macOS)
// without qemu-img a state can only go while QEMU runs, which the next suspend sees to
guard await monitor != nil else {
return
}
#endif
try await deleteStaleSnapshots(staleNames)
}
/// Delete states that nothing needs anymore. Each stays listed in the manifest until it is
/// gone so that one that fails is tried again instead of staying in the images for good.
private func deleteStaleSnapshots(_ names: [String]) async throws {
var firstError: Error?
for name in names {
do {
try await _deleteSnapshot(name: name)
} catch UTMSnapshotError.notFound(_) {
// already gone
} catch {
firstError = firstError ?? error
continue
}
try UTMSnapshotManifest.forgetState(name, in: pathUrl)
}
if let error = firstError {
throw error
}
try await _deleteSnapshot(name: name ?? kSuspendSnapshotName)
}
private func _resume() async throws {
@@ -684,25 +737,13 @@ extension UTMQemuVirtualMachine {
}
func restoreSnapshot(name: String? = nil) async throws {
// A stopped VM is restored by booting it directly into the requested snapshot tag,
// reusing the suspend startup path via `pendingRestoreSnapshotName`.
if state == .stopped, let name {
pendingRestoreSnapshotName = name
do {
try await start()
} catch {
pendingRestoreSnapshotName = nil
throw error
}
return
}
guard state == .paused || state == .started else {
throw UTMQemuVirtualMachineError.invalidVmState
}
let prev = state
state = .restoring
do {
try await _restoreSnapshot(name: name ?? kSuspendSnapshotName)
try await _restoreSnapshot(name: name ?? suspendSnapshotName)
state = prev
} catch {
if prev == .started, let monitor = await monitor {
+85
View File
@@ -0,0 +1,85 @@
//
// Copyright © 2026 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
/// State of a snapshot as it is stored by a backend
struct UTMSnapshotBackendEntry: Equatable {
/// Unique in the backend, what `UTMSnapshotManifest.Snapshot.backendIdentifier` links to
let identifier: String
let date: Date
/// Bytes occupied by the snapshot
let size: Int64
/// Includes the running state of the VM and not just the disks
let hasState: Bool
/// Covers every image that takes part in a snapshot.
///
/// One that does not, such as a snapshot taken before a drive was added, cannot be restored
/// without leaving the drives holding contents from different points in time.
var isComplete: Bool = true
/// Snapshot this one was taken from, for backends that keep track of it
var parentIdentifier: String? = nil
}
/// What can be done with the state of a snapshot
enum UTMSnapshotOperation {
/// Save the current state of the VM
case create
/// Replace the current state of the VM
case restore
case delete
}
/// Where the state of snapshots is kept. There is an implementation for each kind of VM and,
/// where they differ, for a VM that is running (live) and one that is not (frozen).
///
/// A backend knows nothing about `UTMSnapshotManifest`, use `UTMSnapshotService` instead.
protocol UTMSnapshotBackend {
/// Identifier of the state a suspended VM resumes from when the manifest does not name one
var defaultSuspendIdentifier: String { get }
/// Where a snapshot is saved, so without any there is nothing to save
var imageURLs: [URL] { get }
/// `entries()` lists the snapshots of exactly `imageURLs` and knows which are complete, which
/// is needed to add snapshots from the backend or to find those whose state is gone
var isInventoryExact: Bool { get }
/// An operation is possible while the VM is in the state the backend was picked for.
/// - Parameters:
/// - operation: Operation to perform
/// - hasState: The snapshot includes the running state of the VM, ignored for `.create`
func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool
/// Every snapshot found in the backend, including those UTM did not create
func entries() async throws -> [UTMSnapshotBackendEntry]
/// Save the current state of the VM.
/// - Parameter suspendIdentifier: State the VM is suspended to, if it is. It already holds the
/// current state so a backend may return it as the snapshot.
func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry
/// Replace the current state of the VM with a snapshot.
/// - Returns: True if this only takes effect once the VM resumes from the snapshot when it is started
func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool
func delete(identifier: String) async throws
}
+505 -89
View File
@@ -15,138 +15,554 @@
//
import Foundation
import Logging
private let snapshotLogger = Logger(label: "com.utmapp.UTM.snapshot") { label in
UTMLoggingSwift(label: label)
}
/// Coordinates named full-VM snapshots for the QEMU backend.
/// Manages the snapshots of a VM regardless of its backend and of whether it is running.
///
/// This is distinct from the internal suspend/resume feature which uses the reserved name
/// `"suspend"` and `registryEntry.isSuspended`; named snapshots never mark a VM as suspended.
/// The state of a snapshot is kept by a `UTMSnapshotBackend`. Everything else is kept in the
/// `UTMSnapshotManifest` of the bundle which links to that state. Only this service changes the
/// snapshots in a manifest so that the two stay consistent.
///
/// A snapshot never changes unless it is overwritten. Restoring one replaces the current state of
/// the VM with a copy and the snapshot it was restored from is remembered as what the current
/// state is based on, which is how snapshots get their lineage.
///
/// Suspending is closely related: the state a VM is suspended to can be kept as a snapshot and
/// restoring a snapshot makes a stopped VM resume from it. The VM itself keeps track of what it
/// resumes from through the suspend state operations of `UTMSnapshotManifest`.
@MainActor
enum UTMSnapshotService {
/// Snapshot name reserved for the internal suspend/resume feature.
private static let reservedSuspendName = "suspend"
fileprivate typealias Entry = UTMSnapshotManifest.Snapshot
/// Create a named snapshot capturing the full VM state (RAM + devices + disk).
/// Posted with the bundle of the VM as its object whenever its snapshots change
static let didChangeNotification = UTMSnapshotManifest.didChangeNotification
// MARK: - Reading
/// Snapshots can be managed for this VM, whether it is running or not
static func isSupported(for vm: any UTMVirtualMachine) -> Bool {
#if os(macOS)
if vm is UTMQemuVirtualMachine {
return true
}
#endif
// qemu-img is needed for a VM that is not running and is only part of the macOS app
return false
}
/// Snapshots of a VM and how the current state relates to them. The first time, those that
/// are already in the backend are added.
static func timeline(for vm: any UTMVirtualMachine) async throws -> UTMSnapshotTimeline {
var manifest = try UTMSnapshotManifest.load(from: vm.pathUrl) ?? UTMSnapshotManifest()
if !manifest.isBackendImported, let backend = try? backend(for: vm), backend.isInventoryExact {
manifest = try await importUnlinkedSnapshots(from: backend, on: vm)
}
return UTMSnapshotTimeline(snapshots: manifest.snapshots.map { UTMSnapshot($0, on: vm) },
currentParentID: manifest.currentParentID,
isCurrentStateSaved: isCurrentStateSaved(in: manifest, on: vm))
}
/// An operation is possible while the VM is in its current state.
///
/// QEMU replaces an existing snapshot with the same name.
/// Snapshots can be managed whether the VM is running or not, but not everything can be done
/// either way. A running QEMU can only deal with snapshots that include the running state and
/// the disks of a running Apple VM cannot be replaced.
/// - Parameters:
/// - operation: Operation to perform
/// - snapshot: Snapshot to restore or delete
/// - vm: VM the snapshot belongs to
static func isSupported(_ operation: UTMSnapshotOperation, for snapshot: UTMSnapshot? = nil, on vm: any UTMVirtualMachine) -> Bool {
guard let backend = try? backend(for: vm) else {
return false
}
if operation == .create && backend.imageURLs.isEmpty {
return false
}
return backend.supports(operation, hasState: snapshot?.hasState ?? true)
}
/// Nothing changed since the snapshot the current state is based on, which is known when the
/// VM is suspended to the state of that very snapshot.
///
/// Saving the current state again would only duplicate that snapshot.
private static func isCurrentStateSaved(in manifest: UTMSnapshotManifest, on vm: any UTMVirtualMachine) -> Bool {
// a VM that is not stopped may have run since it was suspended
guard vm.state == .stopped, let backend = try? backend(for: vm), let parentID = manifest.currentParentID else {
return false
}
let suspendIdentifier = vm.registryEntry.isSuspended ? manifest.suspendIdentifier ?? backend.defaultSuspendIdentifier : nil
return manifest[parentID]?.backendIdentifier == suspendIdentifier
}
fileprivate static func screenshotURL(for snapshot: Entry, on vm: any UTMVirtualMachine) -> URL? {
snapshot.screenshotName.map { UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent($0) }
}
// MARK: - Operations
/// Save the current state of a VM as a new snapshot.
/// - Parameters:
/// - title: Display name, a default is used if nil
/// - vm: VM that is either running or not
@discardableResult
static func createSnapshot(title: String? = nil, on vm: any UTMVirtualMachine) async throws -> UTMSnapshot {
let backend = try backend(for: vm)
guard !backend.imageURLs.isEmpty else {
throw UTMSnapshotError.noDrives
}
guard backend.supports(.create, hasState: true) else {
throw UTMSnapshotError.requiresStoppedVm
}
let suspendIdentifier = try suspendIdentifier(of: vm, in: backend)
let entry = try await backend.create(suspendIdentifier: suspendIdentifier)
logger.debug("Created snapshot '\(entry.identifier)'")
let screenshotName = saveScreenshot(of: vm)
let isSuspendStateKept = suspendIdentifier != nil && entry.hasState && vm.state != .started
let title = title.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.flatMap { $0.isEmpty ? nil : $0 }
let (snapshot, staleIdentifiers) = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> (Entry, [String]) in
if isSuspendStateKept {
// the VM still resumes from the state which is now the one of the snapshot
manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier)
}
let snapshot = Entry(title: title ?? manifest.defaultTitle,
parentID: manifest.currentParentID,
date: entry.date,
size: entry.size,
hasState: entry.hasState,
backendIdentifier: entry.identifier,
screenshotName: screenshotName)
manifest.insert(snapshot)
manifest.currentParentID = snapshot.id
return (snapshot, manifest.staleIdentifiers)
}
await deleteStaleStates(staleIdentifiers, from: backend, on: vm)
return UTMSnapshot(snapshot, on: vm)
}
/// Replace what a snapshot holds with the current state of a VM.
///
/// Everything else about the snapshot stays, including the snapshots based on it.
static func overwriteSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws {
let backend = try backend(for: vm)
guard let original = try UTMSnapshotManifest.load(from: vm.pathUrl)?[id] else {
throw UTMSnapshotError.notFound(id.uuidString)
}
guard !backend.imageURLs.isEmpty else {
throw UTMSnapshotError.noDrives
}
guard backend.supports(.create, hasState: true) && backend.supports(.delete, hasState: original.hasState) else {
throw UTMSnapshotError.requiresStoppedVm
}
let suspendIdentifier = try suspendIdentifier(of: vm, in: backend)
let entry = try await backend.create(suspendIdentifier: suspendIdentifier)
logger.debug("Overwriting snapshot '\(original.backendIdentifier)' with '\(entry.identifier)'")
let screenshotName = saveScreenshot(of: vm)
let isSuspendStateKept = suspendIdentifier != nil && entry.hasState && vm.state != .started
let staleIdentifiers = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> [String] in
if isSuspendStateKept {
manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier)
}
if var snapshot = manifest[id] {
snapshot.backendIdentifier = entry.identifier
snapshot.size = entry.size
snapshot.hasState = entry.hasState
snapshot.screenshotName = screenshotName
snapshot.dateModified = Date()
snapshot.isOrphaned = false
snapshot.isIncomplete = false
manifest[id] = snapshot
}
manifest.currentParentID = id
manifest.markStale(original.backendIdentifier)
return manifest.staleIdentifiers
}
await deleteStaleStates(staleIdentifiers, from: backend, on: vm)
if let screenshotName = original.screenshotName {
try? FileManager.default.removeItem(at: UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent(screenshotName))
}
}
/// - Returns: The snapshot with the title it ended up with
@discardableResult
static func renameSnapshot(_ id: UUID, to title: String, on vm: any UTMVirtualMachine) throws -> UTMSnapshot {
let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
let snapshot = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> Entry in
guard var snapshot = manifest[id] else {
throw UTMSnapshotError.notFound(id.uuidString)
}
guard !title.isEmpty && snapshot.title != title else {
return snapshot
}
snapshot.title = title
snapshot.dateModified = Date()
manifest[id] = snapshot
return snapshot
}
return UTMSnapshot(snapshot, on: vm)
}
/// Delete a snapshot, along with its state once nothing else links to it.
static func deleteSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws {
let backend = try backend(for: vm)
guard let manifest = try UTMSnapshotManifest.load(from: vm.pathUrl), let snapshot = manifest[id] else {
throw UTMSnapshotError.notFound(id.uuidString)
}
guard backend.supports(.delete, hasState: snapshot.hasState) else {
throw UTMSnapshotError.requiresStoppedVm
}
var remaining = manifest
remaining.remove(id)
if !remaining.isReferenced(backendIdentifier: snapshot.backendIdentifier) {
do {
try await backend.delete(identifier: snapshot.backendIdentifier)
logger.debug("Deleted snapshot '\(snapshot.backendIdentifier)'")
} catch UTMSnapshotError.notFound(_) {
// orphaned
} catch {
// some drives may have lost the state already, so it must not look restorable
_ = try? await identifyOrphanedSnapshots(on: vm)
throw error
}
}
try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in
manifest.remove(id)
}
if let screenshotName = snapshot.screenshotName {
try? FileManager.default.removeItem(at: UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent(screenshotName))
}
}
/// Replace the current state of a VM with a snapshot.
///
/// A running VM continues from the snapshot right away. Otherwise it resumes from it, or boots
/// from its disks if the snapshot has no running state, the next time it is started.
static func restoreSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws {
let backend = try backend(for: vm)
guard let snapshot = try UTMSnapshotManifest.load(from: vm.pathUrl)?[id] else {
throw UTMSnapshotError.notFound(id.uuidString)
}
guard backend.supports(.restore, hasState: snapshot.hasState) else {
throw UTMSnapshotError.requiresStoppedVm
}
guard let entry = try await backend.entries().first(where: { $0.identifier == snapshot.backendIdentifier }) else {
try? await identifyOrphanedSnapshots(on: vm)
throw UTMSnapshotError.orphaned(snapshot.title)
}
guard entry.isComplete else {
try? await identifyOrphanedSnapshots(on: vm)
throw UTMSnapshotError.incomplete(snapshot.title)
}
// a state left named after a start that failed is replaced as well
let previousSuspendIdentifier = try suspendIdentifier(of: vm, in: backend)
let isResumedOnStart = try await backend.restore(entry)
logger.debug("Restored snapshot '\(entry.identifier)'")
let staleIdentifiers = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> [String] in
manifest.replaceSuspendState(previousSuspendIdentifier, with: isResumedOnStart ? entry.identifier : nil)
manifest.currentParentID = id
return manifest.staleIdentifiers
}
vm.registryEntry.isSuspended = isResumedOnStart
// the state that was suspended to is replaced
await deleteStaleStates(staleIdentifiers, from: backend, on: vm)
// the current state is a copy of the snapshot now, so show what the snapshot shows
restoreScreenshot(of: snapshot, on: vm)
}
// MARK: - Consistency with the backend
/// Find snapshots whose state is no longer in the backend, such as after another tool
/// modified it, and mark them in the manifest.
///
/// Call this when something that depends on the state of a snapshot fails.
static func identifyOrphanedSnapshots(on vm: any UTMVirtualMachine) async throws {
let backend = try backend(for: vm)
guard backend.isInventoryExact else {
return
}
let entries = try await backend.entries()
try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in
for index in manifest.snapshots.indices {
let entry = entries.first { $0.identifier == manifest.snapshots[index].backendIdentifier }
manifest.snapshots[index].isOrphaned = entry == nil
// the state is there but does not cover every drive, which is not the same as gone
manifest.snapshots[index].isIncomplete = entry.map { !$0.isComplete } ?? false
}
manifest.forgetStaleStates(notIn: entries)
}
}
/// Add a snapshot for every state in the backend that none links to, such as those made by
/// another tool, and refresh what the backend knows about the others.
///
/// Anything the backend does not store is generated.
@discardableResult
private static func importUnlinkedSnapshots(from backend: any UTMSnapshotBackend, on vm: any UTMVirtualMachine) async throws -> UTMSnapshotManifest {
let entries = try await backend.entries().sorted { $0.date < $1.date }
return try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in
for index in manifest.snapshots.indices {
if let entry = entries.first(where: { $0.identifier == manifest.snapshots[index].backendIdentifier }) {
manifest.snapshots[index].size = entry.size
manifest.snapshots[index].hasState = entry.hasState
manifest.snapshots[index].isOrphaned = false
manifest.snapshots[index].isIncomplete = !entry.isComplete
}
}
manifest.forgetStaleStates(notIn: entries)
for entry in entries where !manifest.isReferenced(backendIdentifier: entry.identifier) {
guard entry.identifier != backend.defaultSuspendIdentifier && !manifest.staleIdentifiers.contains(entry.identifier) else {
// only ever used for suspending, or waiting to be deleted
continue
}
logger.debug("Importing snapshot '\(entry.identifier)'")
// an identifier that is not generated was chosen by someone
let title = UUID(uuidString: entry.identifier) == nil ? manifest.uniqueTitle(entry.identifier) : manifest.defaultTitle
let parent = manifest.snapshots.first { $0.backendIdentifier == entry.parentIdentifier }
var snapshot = Entry(title: title,
parentID: parent?.id,
date: entry.date,
size: entry.size,
hasState: entry.hasState,
backendIdentifier: entry.identifier)
snapshot.isIncomplete = !entry.isComplete
manifest.insert(snapshot)
}
manifest.isBackendImported = true
return manifest
}
}
// MARK: - Snapshots by name
/// Create a snapshot or overwrite the one with the same title.
static func createSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
_ = try requireQemuBackend(vm)
try validate(name: name)
if let error = vm.snapshotUnsupportedError {
throw error
if let snapshot = try await timeline(for: vm).snapshots.first(where: { $0.title == name }) {
try await overwriteSnapshot(snapshot.id, on: vm)
} else {
try await createSnapshot(title: name, on: vm)
}
// capturing RAM through the QEMU monitor requires the VM to be running or paused
guard vm.state == .started || vm.state == .paused else {
throw UTMSnapshotError.invalidVmState
}
snapshotLogger.debug("Creating snapshot '\(name)' on QEMU VM")
try await vm.saveSnapshot(name: name)
}
/// List full-VM snapshots stored in the VM's bundled writable disk images, newest first.
static func listSnapshots(on vm: any UTMVirtualMachine) async throws -> [UTMQemuImage.QemuSnapshotInfo] {
let qemu = try requireQemuBackend(vm)
guard vm.state == .stopped else {
throw UTMSnapshotError.listRequiresStoppedVm
}
var snapshotsByName = [String: UTMQemuImage.QemuSnapshotInfo]()
var imageURLs = qemu.config.drives.compactMap { drive -> URL? in
guard drive.imageType == .disk && !drive.isExternal && !drive.isReadOnly else {
return nil
}
return drive.imageURL
}
if qemu.config.qemu.hasUefiBoot,
let efiVarsURL = qemu.config.qemu.efiVarsURL,
FileManager.default.fileExists(atPath: efiVarsURL.path) {
imageURLs.insert(efiVarsURL, at: 0)
}
for imageURL in imageURLs {
let imageInfo = try await UTMQemuImage.info(image: imageURL)
for snapshot in imageInfo.snapshots ?? [] where snapshot.vmStateSize > 0 && snapshot.name != reservedSuspendName {
snapshotsByName[snapshot.name] = snapshot
}
}
return snapshotsByName.values.sorted { $0.date > $1.date }
/// Titles of all snapshots, newest first
static func listSnapshots(on vm: any UTMVirtualMachine) async throws -> [String] {
_ = try backend(for: vm)
return try await timeline(for: vm).snapshots.map { $0.title }
}
/// Restore the VM to a previously captured named snapshot.
static func restoreSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
_ = try requireQemuBackend(vm)
try validate(name: name)
guard vm.state == .stopped else {
throw UTMSnapshotError.restoreRequiresStoppedVm
}
guard try await listSnapshots(on: vm).contains(where: { $0.name == name }) else {
try await restoreSnapshot(try await snapshot(named: name, on: vm).id, on: vm)
}
static func deleteSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
try await deleteSnapshot(try await snapshot(named: name, on: vm).id, on: vm)
}
private static func snapshot(named name: String, on vm: any UTMVirtualMachine) async throws -> UTMSnapshot {
guard let snapshot = try await timeline(for: vm).snapshots.first(where: { $0.title == name }) else {
throw UTMSnapshotError.notFound(name)
}
snapshotLogger.debug("Restoring snapshot '\(name)' on QEMU VM")
try await vm.restoreSnapshot(name: name)
}
/// Delete a named snapshot and its backend state.
static func deleteSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
_ = try requireQemuBackend(vm)
try validate(name: name)
// QEMU deletes the tag from the running qcow2 via the monitor, so it must be running.
if vm.state != .started && vm.state != .paused {
throw UTMSnapshotError.invalidVmState
}
snapshotLogger.debug("Deleting snapshot '\(name)' on QEMU VM")
try await vm.deleteSnapshot(name: name)
return snapshot
}
// MARK: - Helpers
private static func requireQemuBackend(_ vm: any UTMVirtualMachine) throws -> UTMQemuVirtualMachine {
guard let qemu = vm as? UTMQemuVirtualMachine else {
/// Pick the backend for the kind of VM and for whether it is running
private static func backend(for vm: any UTMVirtualMachine) throws -> any UTMSnapshotBackend {
guard !vm.isRunningAsDisposible else {
throw UTMSnapshotError.notSupported
}
return qemu
guard isSupported(for: vm) else {
throw UTMSnapshotError.notSupported
}
if let qemu = vm as? UTMQemuVirtualMachine {
switch vm.state {
case .started, .paused: return UTMQemuLiveSnapshotBackend(vm: qemu, imageURLs: qemu.snapshotImageURLs)
case .stopped: return UTMQemuFrozenSnapshotBackend(imageURLs: qemu.snapshotImageURLs, vm: qemu)
default: throw UTMSnapshotError.invalidVmState
}
}
throw UTMSnapshotError.notSupported
}
private static func validate(name: String) throws {
if name.caseInsensitiveCompare(reservedSuspendName) == .orderedSame {
throw UTMSnapshotError.reservedName(name)
/// State the VM is suspended to, if it is
private static func suspendIdentifier(of vm: any UTMVirtualMachine, in backend: any UTMSnapshotBackend) throws -> String? {
guard vm.registryEntry.isSuspended else {
return nil
}
let pattern = "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$"
guard name.range(of: pattern, options: .regularExpression) != nil else {
throw UTMSnapshotError.invalidName(name)
return try UTMSnapshotManifest.load(from: vm.pathUrl)?.suspendIdentifier ?? backend.defaultSuspendIdentifier
}
/// Delete states that nothing needs anymore. Each stays listed in the manifest until it is
/// gone so that one that fails, such as one a running VM cannot delete, is tried again the
/// next time instead of staying in the backend for good.
private static func deleteStaleStates(_ identifiers: [String], from backend: any UTMSnapshotBackend, on vm: any UTMVirtualMachine) async {
for identifier in identifiers {
do {
try await backend.delete(identifier: identifier)
logger.debug("Deleted stale state '\(identifier)'")
} catch UTMSnapshotError.notFound(_) {
// already gone
} catch {
logger.debug("Keeping stale state '\(identifier)': \(error)")
continue
}
try? UTMSnapshotManifest.forgetState(identifier, in: vm.pathUrl)
}
}
/// Keep the most recent screenshot of the VM for a snapshot.
/// - Returns: Name of the file in the screenshots directory
private static func saveScreenshot(of vm: any UTMVirtualMachine) -> String? {
let fileManager = FileManager.default
let screenshotsURL = UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl)
let name = UUID().uuidString + ".png"
if vm.state != .stopped {
try? vm.saveScreenshot()
}
do {
try fileManager.createDirectory(at: screenshotsURL, withIntermediateDirectories: true)
try fileManager.copyItem(at: vm.pathUrl.appendingPathComponent(kUTMBundleScreenshotFilename), to: screenshotsURL.appendingPathComponent(name))
return name
} catch {
return nil
}
}
/// Show what a snapshot shows for the current state of a VM.
///
/// A snapshot taken while the screen was not being kept has no picture of its own, and then
/// the one on show belongs to a state the VM is no longer in, so it goes.
private static func restoreScreenshot(of snapshot: Entry, on vm: any UTMVirtualMachine) {
let fileManager = FileManager.default
let currentURL = vm.pathUrl.appendingPathComponent(kUTMBundleScreenshotFilename)
try? fileManager.removeItem(at: currentURL)
if let screenshotURL = screenshotURL(for: snapshot, on: vm), fileManager.fileExists(atPath: screenshotURL.path) {
try? fileManager.copyItem(at: screenshotURL, to: currentURL)
}
try? vm.reloadScreenshotFromFile()
}
}
// MARK: - Read model
/// A snapshot of a VM as it is shown and scripted
struct UTMSnapshot: Identifiable, Equatable {
let id: UUID
/// User-editable display name
var title: String
/// Snapshot the current state was based on when this one was created, `nil` if there was none
var parentID: UUID?
var dateCreated: Date
/// Last time the snapshot was renamed or overwritten
var dateModified: Date
/// Bytes occupied in the backend
var size: Int64
/// Includes the running state of the VM, otherwise it starts up from the saved disks
var hasState: Bool
/// The saved state is missing so the snapshot can only be deleted
var isOrphaned: Bool
/// The saved state does not cover every drive so the snapshot cannot be restored
var isIncomplete: Bool
/// Screen contents when the snapshot was taken, if one was captured
var screenshotURL: URL?
@MainActor
fileprivate init(_ entry: UTMSnapshotManifest.Snapshot, on vm: any UTMVirtualMachine) {
id = entry.id
title = entry.title
parentID = entry.parentID
dateCreated = entry.dateCreated
dateModified = entry.dateModified
size = entry.size
hasState = entry.hasState
isOrphaned = entry.isOrphaned
isIncomplete = entry.isIncomplete
screenshotURL = UTMSnapshotService.screenshotURL(for: entry, on: vm)
}
}
/// The snapshots of a VM along with the current state they are listed under
struct UTMSnapshotTimeline: Equatable {
/// Newest first
var snapshots: [UTMSnapshot]
/// Snapshot the current state descends from, `nil` if there is none
var currentParentID: UUID?
/// Nothing changed since the snapshot the current state is based on
var isCurrentStateSaved: Bool
}
private extension UTMSnapshotManifest {
/// Stop resuming from `previous`, the state the VM was found suspended to, which the manifest
/// may not name when it was suspended by an older version
mutating func replaceSuspendState(_ previous: String?, with backendIdentifier: String?) {
if suspendIdentifier == nil {
suspendIdentifier = previous
}
setSuspendState(backendIdentifier)
}
/// States that were to be deleted and are gone from an exact inventory need no deleting
mutating func forgetStaleStates(notIn entries: [UTMSnapshotBackendEntry]) {
staleIdentifiers.removeAll { identifier in !entries.contains { $0.identifier == identifier } }
}
var defaultTitle: String {
var number = snapshots.count + 1
var title: String
repeat {
title = String.localizedStringWithFormat(NSLocalizedString("Snapshot %lld", comment: "UTMSnapshotService"), number)
number += 1
} while snapshots.contains { $0.title == title }
return title
}
func uniqueTitle(_ base: String) -> String {
var title = base
var number = 2
while snapshots.contains(where: { $0.title == title }) {
title = "\(base) \(number)"
number += 1
}
return title
}
}
enum UTMSnapshotError: Error {
case reservedName(String)
case invalidName(String)
case notFound(String)
case orphaned(String)
case incomplete(String)
case noDrives
case notSupported
case invalidVmState
case listRequiresStoppedVm
case restoreRequiresStoppedVm
case requiresStoppedVm
}
extension UTMSnapshotError: LocalizedError {
var errorDescription: String? {
switch self {
case .reservedName(let name):
return String.localizedStringWithFormat(NSLocalizedString("The name '%@' is reserved and cannot be used for a snapshot.", comment: "UTMSnapshotService"), name)
case .invalidName(let name):
return String.localizedStringWithFormat(NSLocalizedString("The name '%@' is not a valid snapshot name. Use letters, numbers, and the characters _.- (up to 64 characters).", comment: "UTMSnapshotService"), name)
case .notFound(let name):
return String.localizedStringWithFormat(NSLocalizedString("The snapshot '%@' does not exist.", comment: "UTMSnapshotService"), name)
case .orphaned(let name):
return String.localizedStringWithFormat(NSLocalizedString("The data of the snapshot '%@' is missing. It may have been removed by another application.", comment: "UTMSnapshotService"), name)
case .incomplete(let name):
return String.localizedStringWithFormat(NSLocalizedString("The snapshot '%@' cannot be restored because it does not cover every drive of the virtual machine, such as a drive added after it was taken.", comment: "UTMSnapshotService"), name)
case .noDrives:
return NSLocalizedString("A snapshot needs a drive that the virtual machine can write to.", comment: "UTMSnapshotService")
case .notSupported:
return NSLocalizedString("Snapshots are not supported for this virtual machine.", comment: "UTMSnapshotService")
case .invalidVmState:
return NSLocalizedString("The virtual machine is in an invalid state for this snapshot operation.", comment: "UTMSnapshotService")
case .listRequiresStoppedVm:
return NSLocalizedString("The virtual machine must be stopped before listing snapshots.", comment: "UTMSnapshotService")
case .restoreRequiresStoppedVm:
return NSLocalizedString("The virtual machine must be stopped before restoring a snapshot.", comment: "UTMSnapshotService")
case .requiresStoppedVm:
return NSLocalizedString("The virtual machine must be stopped for this snapshot operation.", comment: "UTMSnapshotService")
}
}
}
+16
View File
@@ -140,12 +140,18 @@
8453DCB2278CE3D10037A0DA /* qemu-img.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = 8453DCB0278CE33E0037A0DA /* qemu-img.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
8453DCB4278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
393A92DAE84A47029BA5F829 /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
738DDF2A88F46375A0D72177 /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; };
EFAB3C24B8C39BC85EEFE028 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; };
AFC3FBE9C92083C52D800437 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; };
8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
8F8F6CA6377BBED5737019BB /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; };
12618B76026270B5C1C8CD29 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; };
BCB19D49AC1352354193ACB6 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; };
8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
63353664DA55B4F6AC8BD07B /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; };
6E14FA50A12736E32EA02200 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; };
E1B70310A596175A0E9F3025 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; };
845F1705289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1704289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift */; };
845F1707289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */; };
@@ -1792,6 +1798,8 @@
8453DCB0278CE33E0037A0DA /* qemu-img.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "qemu-img.framework"; path = "$(SYSROOT_DIR)/Frameworks/qemu-img.framework"; sourceTree = "<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>"; };
@@ -2997,6 +3005,8 @@
CE9D197B226542FE00355E14 /* UTMProcess.m */,
8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */,
EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */,
F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */,
DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */,
84A0A8822A47D52E0038F329 /* UTMQemuPort.swift */,
CE03D05424D90BE000F76B84 /* UTMQemuSystem.h */,
CE03D05024D90B4E00F76B84 /* UTMQemuSystem.m */,
@@ -3916,6 +3926,8 @@
848D99A8285DB5550055C215 /* VMConfigConstantPicker.swift in Sources */,
8453DCB4278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
393A92DAE84A47029BA5F829 /* UTMSnapshotService.swift in Sources */,
738DDF2A88F46375A0D72177 /* UTMQemuSnapshotBackend.swift in Sources */,
EFAB3C24B8C39BC85EEFE028 /* UTMSnapshotBackend.swift in Sources */,
AFC3FBE9C92083C52D800437 /* UTMSnapshotManifest.swift in Sources */,
CE2D955924AD4F980059923A /* VMToolbarModifier.swift in Sources */,
CE2D92DA24AD46670059923A /* VMCursor.m in Sources */,
@@ -4164,6 +4176,8 @@
CE2D956224AD4F990059923A /* VMSettingsView.swift in Sources */,
8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */,
63353664DA55B4F6AC8BD07B /* UTMQemuSnapshotBackend.swift in Sources */,
6E14FA50A12736E32EA02200 /* UTMSnapshotBackend.swift in Sources */,
E1B70310A596175A0E9F3025 /* UTMSnapshotManifest.swift in Sources */,
CEDF83FA258AE24E0030E4AC /* UTMPasteboard.swift in Sources */,
848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */,
@@ -4309,6 +4323,8 @@
848A98C5286F332D006F0550 /* UTMConfiguration.swift in Sources */,
8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */,
8F8F6CA6377BBED5737019BB /* UTMQemuSnapshotBackend.swift in Sources */,
12618B76026270B5C1C8CD29 /* UTMSnapshotBackend.swift in Sources */,
BCB19D49AC1352354193ACB6 /* UTMSnapshotManifest.swift in Sources */,
84C60FBB268269D700B58C00 /* VMDisplayViewController.swift in Sources */,
8443EFFB28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */,
@@ -52,7 +52,7 @@
"location" : "https://github.com/utmapp/QEMUKit.git",
"state" : {
"branch" : "main",
"revision" : "589765abff27a8764d58b1a90999a204ac09881e"
"revision" : "14f445a7333edc7113f9e37f6d2915b1c5ca00b8"
}
},
{