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.")