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