From a11e96808423b894a71ef040ad725102b75df946 Mon Sep 17 00:00:00 2001
From: Naveenraj M <22456988+naveenrajm7@users.noreply.github.com>
Date: Thu, 20 Aug 2026 13:26:29 -0700
Subject: [PATCH] snapshots: expose QEMU full-VM snapshots
Expose named create, list, restore, and delete operations through AppleScript and utmctl so automation can manage QEMU full-VM snapshots. QEMU image metadata remains the source of truth, the internal suspend snapshot stays hidden and reserved, and Apple virtualization reports the feature as unsupported until it can preserve equivalent VM state reliably.
Assisted-by: Codex:gpt-5
---
Scripting/UTM.sdef | 44 +++++
Scripting/UTMScripting.swift | 4 +
.../UTMScriptingVirtualMachineImpl.swift | 37 +++++
Services/UTMAppleVirtualMachine.swift | 9 ++
Services/UTMQemuImage.swift | 40 ++++-
Services/UTMQemuVirtualMachine.swift | 48 +++++-
Services/UTMSnapshotService.swift | 152 ++++++++++++++++++
UTM.xcodeproj/project.pbxproj | 8 +
utmctl/UTMCtl.swift | 96 ++++++++++-
9 files changed, 427 insertions(+), 11 deletions(-)
create mode 100644 Services/UTMSnapshotService.swift
diff --git a/Scripting/UTM.sdef b/Scripting/UTM.sdef
index 484aca9907..81547f6752 100644
--- a/Scripting/UTM.sdef
+++ b/Scripting/UTM.sdef
@@ -83,6 +83,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -148,6 +176,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Scripting/UTMScripting.swift b/Scripting/UTMScripting.swift
index 0121c4a6de..1b8dfb3f9a 100644
--- a/Scripting/UTMScripting.swift
+++ b/Scripting/UTMScripting.swift
@@ -230,6 +230,10 @@ extension SBObject: UTMScriptingWindow {}
@objc optional func startSaving(_ saving: Bool, recovery: Bool) // Start a virtual machine or resume a suspended virtual machine.
@objc optional func suspendSaving(_ saving: Bool) // Suspend a running virtual machine to memory.
@objc optional func stopBy(_ by: UTMScriptingStopMethod) // Shuts down a running virtual machine.
+ @objc optional func createSnapshotNamed(_ named: String!) // Create or replace a named full-VM snapshot (RAM, devices, and disk) for a QEMU virtual machine. The VM must be running or paused.
+ @objc optional func listSnapshots() -> [Any] // List the names of all named snapshots for a stopped QEMU virtual machine, newest first.
+ @objc optional func restoreSnapshotNamed(_ named: String!) // Restore a stopped QEMU virtual machine to a named snapshot.
+ @objc optional func deleteSnapshotNamed(_ named: String!) // Delete a named snapshot from a QEMU virtual machine.
@objc optional func delete() // Delete a virtual machine. All data will be deleted, there is no confirmation!
@objc optional func duplicateWithProperties(_ withProperties: [AnyHashable : Any]!) // Copy an virtual machine and all its data.
@objc optional func exportTo(_ to: URL!) // Export a virtual machine to a specified location.
diff --git a/Scripting/UTMScriptingVirtualMachineImpl.swift b/Scripting/UTMScriptingVirtualMachineImpl.swift
index d8fab05c33..867d43ff3a 100644
--- a/Scripting/UTMScriptingVirtualMachineImpl.swift
+++ b/Scripting/UTMScriptingVirtualMachineImpl.swift
@@ -175,6 +175,43 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
}
}
+ @objc func createSnapshot(_ command: NSScriptCommand) {
+ let name = command.evaluatedArguments?["snapshotName"] as? String
+ withScriptCommand(command) { [self] in
+ guard let name = name else {
+ throw ScriptingError.invalidParameter
+ }
+ try await UTMSnapshotService.createSnapshot(name: name, on: vm)
+ }
+ }
+
+ @objc func listSnapshots(_ command: NSScriptCommand) {
+ withScriptCommand(command) { [self] in
+ let entries = try await UTMSnapshotService.listSnapshots(on: vm)
+ return entries.map { $0.name }
+ }
+ }
+
+ @objc func restoreSnapshot(_ command: NSScriptCommand) {
+ let name = command.evaluatedArguments?["snapshotName"] as? String
+ withScriptCommand(command) { [self] in
+ guard let name = name else {
+ throw ScriptingError.invalidParameter
+ }
+ try await UTMSnapshotService.restoreSnapshot(name: name, on: vm)
+ }
+ }
+
+ @objc func deleteSnapshot(_ command: NSScriptCommand) {
+ let name = command.evaluatedArguments?["snapshotName"] as? String
+ withScriptCommand(command) { [self] in
+ guard let name = name else {
+ throw ScriptingError.invalidParameter
+ }
+ try await UTMSnapshotService.deleteSnapshot(name: name, on: vm)
+ }
+ }
+
@objc func delete(_ command: NSDeleteCommand) {
withScriptCommand(command) { [self] in
guard vm.state == .stopped else {
diff --git a/Services/UTMAppleVirtualMachine.swift b/Services/UTMAppleVirtualMachine.swift
index 89d24688a2..85e3a4d266 100644
--- a/Services/UTMAppleVirtualMachine.swift
+++ b/Services/UTMAppleVirtualMachine.swift
@@ -376,6 +376,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
#endif
func saveSnapshot(name: String? = nil) async throws {
+ guard name == nil else {
+ throw UTMSnapshotError.notSupported
+ }
guard #available(macOS 14, *) else {
return
}
@@ -402,6 +405,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
}
func deleteSnapshot(name: String? = nil) async throws {
+ guard name == nil else {
+ throw UTMSnapshotError.notSupported
+ }
guard let vmSavedStateURL = await config.system.boot.vmSavedStateURL else {
return
}
@@ -432,6 +438,9 @@ final class UTMAppleVirtualMachine: UTMVirtualMachine {
#endif
func restoreSnapshot(name: String? = nil) async throws {
+ guard name == nil else {
+ throw UTMSnapshotError.notSupported
+ }
guard #available(macOS 14, *) else {
throw UTMAppleVirtualMachineError.operationNotAvailable
}
diff --git a/Services/UTMQemuImage.swift b/Services/UTMQemuImage.swift
index 5ad8353db3..90959c0ee3 100644
--- a/Services/UTMQemuImage.swift
+++ b/Services/UTMQemuImage.swift
@@ -107,13 +107,38 @@ import QEMUKitInternal
}
*/
- struct QemuImageInfo : Codable {
+ struct QemuSnapshotInfo: Codable, Sendable, Equatable {
+ let id: String
+ let name: String
+ let vmStateSize: Int64
+ let dateSec: Int64
+ let dateNsec: Int64
+ let vmClockSec: Int64
+ let vmClockNsec: Int64
+
+ var date: Date {
+ Date(timeIntervalSince1970: TimeInterval(dateSec) + TimeInterval(dateNsec) / 1_000_000_000)
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case name
+ case vmStateSize = "vm-state-size"
+ case dateSec = "date-sec"
+ case dateNsec = "date-nsec"
+ case vmClockSec = "vm-clock-sec"
+ case vmClockNsec = "vm-clock-nsec"
+ }
+ }
+
+ struct QemuImageInfo: Codable {
let virtualSize : Int64
let filename : String
- let clusterSize : Int32
+ let clusterSize : Int32?
let format : String
let actualSize : Int64
- let dirtyFlag : Bool
+ let dirtyFlag : Bool?
+ let snapshots: [QemuSnapshotInfo]?
private enum CodingKeys: String, CodingKey {
case virtualSize = "virtual-size"
@@ -122,10 +147,11 @@ import QEMUKitInternal
case format
case actualSize = "actual-size"
case dirtyFlag = "dirty-flag"
+ case snapshots
}
}
- static func size(image url: URL) async throws -> Int64 {
+ static func info(image url: URL) async throws -> QemuImageInfo {
let qemuImg = UTMQemuImage()
let srcBookmark = try url.bookmarkData()
qemuImg.pushArgv("info")
@@ -144,7 +170,11 @@ import QEMUKitInternal
let data = qemuImg.logOutput.data(using: .utf8) ?? Data()
let image_info: QemuImageInfo = try decoder.decode(QemuImageInfo.self, from: data)
- return image_info.virtualSize
+ return image_info
+ }
+
+ static func size(image url: URL) async throws -> Int64 {
+ try await info(image: url).virtualSize
}
static func resize(image url: URL, size : UInt64) async throws {
diff --git a/Services/UTMQemuVirtualMachine.swift b/Services/UTMQemuVirtualMachine.swift
index 40df926e00..148f83a8dc 100644
--- a/Services/UTMQemuVirtualMachine.swift
+++ b/Services/UTMQemuVirtualMachine.swift
@@ -103,6 +103,9 @@ 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
private weak var screenshotTimer: Timer?
@@ -428,14 +431,28 @@ 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
- if !isRunningAsDisposible && isSuspended {
- try await monitor.qemuRestoreSnapshot(kSuspendSnapshotName)
+ 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)
+ try Task.checkCancellation()
+ }
+ withMounting = !isSuspended
}
// set up SPICE sharing and removable drives
- try await self.restoreExternalDrives(withMounting: !isSuspended)
+ try await self.restoreExternalDrives(withMounting: withMounting)
if let ioService = interface as? UTMSpiceIO {
try await self.restoreSharedDirectory(for: ioService)
} else {
@@ -446,7 +463,7 @@ extension UTMQemuVirtualMachine {
// continue VM boot
try await monitor.continueBoot()
- // delete saved state
+ // delete saved suspend state (user snapshots are preserved)
if isSuspended {
try? await deleteSnapshot()
}
@@ -667,6 +684,18 @@ 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
}
@@ -676,7 +705,16 @@ extension UTMQemuVirtualMachine {
try await _restoreSnapshot(name: name ?? kSuspendSnapshotName)
state = prev
} catch {
- state = .stopped
+ if prev == .started, let monitor = await monitor {
+ do {
+ try await monitor.qemuResume()
+ state = .started
+ } catch {
+ state = .paused
+ }
+ } else {
+ state = .paused
+ }
throw error
}
}
diff --git a/Services/UTMSnapshotService.swift b/Services/UTMSnapshotService.swift
new file mode 100644
index 0000000000..3454c13955
--- /dev/null
+++ b/Services/UTMSnapshotService.swift
@@ -0,0 +1,152 @@
+//
+// Copyright © 2026 osy. 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 Logging
+
+private let snapshotLogger = Logger(label: "com.utmapp.UTM.snapshot") { label in
+ UTMLoggingSwift(label: label)
+}
+
+/// Coordinates named full-VM snapshots for the QEMU backend.
+///
+/// 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.
+@MainActor
+enum UTMSnapshotService {
+ /// Snapshot name reserved for the internal suspend/resume feature.
+ private static let reservedSuspendName = "suspend"
+
+ /// Create a named snapshot capturing the full VM state (RAM + devices + disk).
+ ///
+ /// QEMU replaces an existing snapshot with the same name.
+ static func createSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
+ _ = try requireQemuBackend(vm)
+ try validate(name: name)
+ if let error = vm.snapshotUnsupportedError {
+ throw error
+ }
+ // 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 }
+ }
+
+ /// 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 {
+ 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)
+ }
+
+ // MARK: - Helpers
+
+ private static func requireQemuBackend(_ vm: any UTMVirtualMachine) throws -> UTMQemuVirtualMachine {
+ guard let qemu = vm as? UTMQemuVirtualMachine else {
+ throw UTMSnapshotError.notSupported
+ }
+ return qemu
+ }
+
+ private static func validate(name: String) throws {
+ if name.caseInsensitiveCompare(reservedSuspendName) == .orderedSame {
+ throw UTMSnapshotError.reservedName(name)
+ }
+ 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)
+ }
+ }
+}
+
+enum UTMSnapshotError: Error {
+ case reservedName(String)
+ case invalidName(String)
+ case notFound(String)
+ case notSupported
+ case invalidVmState
+ case listRequiresStoppedVm
+ case restoreRequiresStoppedVm
+}
+
+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 .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")
+ }
+ }
+}
diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj
index 9b93eff2bb..a3631ef7d6 100644
--- a/UTM.xcodeproj/project.pbxproj
+++ b/UTM.xcodeproj/project.pbxproj
@@ -138,8 +138,11 @@
844EC0FB2773EE49003C104A /* UTMDownloadIPSWTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844EC0FA2773EE49003C104A /* UTMDownloadIPSWTask.swift */; };
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 */; };
8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
+ 41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
+ FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
845F1705289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1704289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift */; };
845F1707289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */; };
845F1709289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1708289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift */; };
@@ -1772,6 +1775,7 @@
844EC0FA2773EE49003C104A /* UTMDownloadIPSWTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDownloadIPSWTask.swift; sourceTree = ""; };
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 = ""; };
845F1704289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleConfigurationGenericPlatform.swift; sourceTree = ""; };
845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMAppleSettingsAddDeviceMenuView.swift; sourceTree = ""; };
845F1708289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayAppleTerminalWindowController.swift; sourceTree = ""; };
@@ -2967,6 +2971,7 @@
CE9D197A226542FE00355E14 /* UTMProcess.h */,
CE9D197B226542FE00355E14 /* UTMProcess.m */,
8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */,
+ EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */,
84A0A8822A47D52E0038F329 /* UTMQemuPort.swift */,
CE03D05424D90BE000F76B84 /* UTMQemuSystem.h */,
CE03D05024D90B4E00F76B84 /* UTMQemuSystem.m */,
@@ -3859,6 +3864,7 @@
CE2D92D724AD46670059923A /* UTMLogging.m in Sources */,
848D99A8285DB5550055C215 /* VMConfigConstantPicker.swift in Sources */,
8453DCB4278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
+ 393A92DAE84A47029BA5F829 /* UTMSnapshotService.swift in Sources */,
CE2D955924AD4F980059923A /* VMToolbarModifier.swift in Sources */,
CE2D92DA24AD46670059923A /* VMCursor.m in Sources */,
CE9375A124BBDDD10074066F /* VMConfigDriveDetailsView.swift in Sources */,
@@ -4101,6 +4107,7 @@
848A98B6286A142C006F0550 /* UTMAppleConfigurationSerial.swift in Sources */,
CE2D956224AD4F990059923A /* VMSettingsView.swift in Sources */,
8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
+ FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */,
CEDF83FA258AE24E0030E4AC /* UTMPasteboard.swift in Sources */,
848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */,
848A98BA286A17A8006F0550 /* UTMAppleConfigurationNetwork.swift in Sources */,
@@ -4238,6 +4245,7 @@
CEA45E94263519B5002FA97D /* UTMLegacyQemuConfiguration+Drives.m in Sources */,
848A98C5286F332D006F0550 /* UTMConfiguration.swift in Sources */,
8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
+ 41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */,
84C60FBB268269D700B58C00 /* VMDisplayViewController.swift in Sources */,
8443EFFB28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */,
848D99BD28636AC90055C215 /* UTMConfigurationDrive.swift in Sources */,
diff --git a/utmctl/UTMCtl.swift b/utmctl/UTMCtl.swift
index 809560d337..d2539f71d7 100644
--- a/utmctl/UTMCtl.swift
+++ b/utmctl/UTMCtl.swift
@@ -37,7 +37,8 @@ struct UTMCtl: ParsableCommand {
IPAddress.self,
Clone.self,
Delete.self,
- USB.self
+ USB.self,
+ Snapshot.self
]
)
}
@@ -653,6 +654,99 @@ extension UTMCtl {
}
}
+extension UTMCtl {
+ struct Snapshot: ParsableCommand {
+ static var configuration = CommandConfiguration(
+ abstract: "Create, list, restore, and delete named full-VM snapshots for QEMU virtual machines.",
+ subcommands: [SnapshotCreate.self, SnapshotList.self, SnapshotRestore.self, SnapshotDelete.self]
+ )
+ }
+
+ struct SnapshotCreate: UTMAPICommand {
+ static var configuration = CommandConfiguration(
+ commandName: "create",
+ abstract: "Create or replace a named snapshot of a running or paused QEMU virtual machine."
+ )
+
+ @OptionGroup var environment: EnvironmentOptions
+
+ @OptionGroup var identifer: VMIdentifier
+
+ @Option(help: "Name of the snapshot to create.")
+ var name: String
+
+ func run(with application: UTMScriptingApplication) throws {
+ let vm = try virtualMachine(forIdentifier: identifer, in: application)
+ vm.createSnapshotNamed!(name)
+ }
+ }
+
+ struct SnapshotList: UTMAPICommand {
+ static var configuration = CommandConfiguration(
+ commandName: "list",
+ abstract: "List the names of all snapshots for a stopped QEMU virtual machine."
+ )
+
+ @OptionGroup var environment: EnvironmentOptions
+
+ @OptionGroup var identifer: VMIdentifier
+
+ @Flag(help: "Output the list as a JSON array.")
+ var json: Bool = false
+
+ func run(with application: UTMScriptingApplication) throws {
+ let vm = try virtualMachine(forIdentifier: identifer, in: application)
+ let names = (vm.listSnapshots!() as? [String]) ?? []
+ if json {
+ let data = try JSONSerialization.data(withJSONObject: names, options: [.prettyPrinted])
+ print(String(data: data, encoding: .utf8) ?? "[]")
+ } else {
+ for name in names {
+ print(name)
+ }
+ }
+ }
+ }
+
+ struct SnapshotRestore: UTMAPICommand {
+ static var configuration = CommandConfiguration(
+ commandName: "restore",
+ abstract: "Restore a stopped QEMU virtual machine to a named snapshot."
+ )
+
+ @OptionGroup var environment: EnvironmentOptions
+
+ @OptionGroup var identifer: VMIdentifier
+
+ @Option(help: "Name of the snapshot to restore.")
+ var name: String
+
+ func run(with application: UTMScriptingApplication) throws {
+ let vm = try virtualMachine(forIdentifier: identifer, in: application)
+ vm.restoreSnapshotNamed!(name)
+ }
+ }
+
+ struct SnapshotDelete: UTMAPICommand {
+ static var configuration = CommandConfiguration(
+ commandName: "delete",
+ abstract: "Delete a named snapshot from a QEMU virtual machine."
+ )
+
+ @OptionGroup var environment: EnvironmentOptions
+
+ @OptionGroup var identifer: VMIdentifier
+
+ @Option(help: "Name of the snapshot to delete.")
+ var name: String
+
+ func run(with application: UTMScriptingApplication) throws {
+ let vm = try virtualMachine(forIdentifier: identifer, in: application)
+ vm.deleteSnapshotNamed!(name)
+ }
+ }
+}
+
extension UTMCtl {
struct VMIdentifier: ParsableArguments {
@Argument(help: "Either the UUID or the complete name of the virtual machine.")