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