scripting: model snapshots as scriptable objects

The snapshot commands could only name a snapshot and could not say anything
about it. A script could not tell when a snapshot was taken, how much it
holds, whether restoring it resumes the guest or starts it up, or whether
its data has gone missing, and there was no way to rename one or to throw
away a suspended state, both of which the window offers.

Make a snapshot an object instead. A virtual machine now has snapshots as
elements, each carrying those details, so a script can pick one out and
read it:

    tell application "UTM"
      set vm to virtual machine named "Debian"
      set candidates to (snapshots of vm whose includes running state)
      restore (item 1 of candidates)
    end tell

Renaming is setting the name, and the commands that act on a snapshot take
the snapshot itself. Creating one returns it. Two more commands cover what
the window can do and scripting could not: scanning the images for
snapshots made by other tools, and discarding a saved state so the guest
starts up instead of resuming.

This replaces the snapshot commands of the 5.0.5 beta, which addressed a
snapshot by name through its virtual machine, so scripts and utmctl calls
written against them have to be updated.

Snapshots are addressed by name, so names are now kept apart the way
virtual machine names are, by appending a number.

Deleting a snapshot is its own command rather than the standard delete,
which coerces a specifier for anything inside a virtual machine into the
virtual machine itself; deleting that would throw away far more than was
asked for. The delete command refuses such a specifier for the same reason.

UTMScripting.swift is regenerated from the sdef; only the hunks that
changed are taken.

Assisted-by: Claude:claude-opus-5
This commit is contained in:
osy
2026-09-22 10:57:36 -05:00
parent e79d3b3bf0
commit b1e1b12a05
8 changed files with 433 additions and 115 deletions
+65 -25
View File
@@ -127,32 +127,32 @@
</parameter> </parameter>
</command> </command>
<command name="create snapshot" code="UTMvcrsn" description="Create or replace a named full-VM snapshot (RAM, devices, and disk) for a QEMU virtual machine. The VM must be running or paused."> <command name="create snapshot" code="UTMvcrsn" description="Save the current state of a virtual machine as a new snapshot. A running QEMU virtual machine is saved along with its running state, otherwise only the disks are.">
<direct-parameter description="Virtual machine to snapshot." type="virtual machine"/> <direct-parameter description="Virtual machine to snapshot." type="virtual machine"/>
<parameter name="named" code="SnNm" description="The name of the snapshot." type="text"> <parameter name="named" code="SnNm" description="Name for the new snapshot. A name already in use gets a number appended. If omitted, a name is generated." type="text" optional="yes">
<cocoa key="snapshotName"/> <cocoa key="snapshotName"/>
</parameter> </parameter>
<result description="The snapshot that was created." type="snapshot"/>
</command> </command>
<command name="list snapshots" code="UTMvlssn" description="List the names of all named snapshots for a stopped QEMU virtual machine, newest first."> <command name="restore" code="UTMvrssn" description="Replace the current state of a virtual machine with a snapshot. A virtual machine that is not running resumes from it the next time it is started.">
<direct-parameter description="Virtual machine to list snapshots for." type="virtual machine"/> <direct-parameter description="Snapshot to restore." type="snapshot"/>
<result description="List of snapshot names.">
<type type="text" list="yes"/>
</result>
</command> </command>
<command name="restore snapshot" code="UTMvrssn" description="Restore a stopped QEMU virtual machine to a named snapshot."> <command name="delete snapshot" code="UTMvdlsn" description="Delete a snapshot. Its data is deleted with it, there is no confirmation!">
<direct-parameter description="Virtual machine to restore." type="virtual machine"/> <direct-parameter description="Snapshot to delete." type="snapshot"/>
<parameter name="named" code="SnNm" description="The name of the snapshot to restore." type="text">
<cocoa key="snapshotName"/>
</parameter>
</command> </command>
<command name="delete snapshot" code="UTMvdlsn" description="Delete a named snapshot from a QEMU virtual machine."> <command name="overwrite" code="UTMvovsn" description="Replace what a snapshot holds with the current state of the virtual machine. The snapshot keeps its name and its place among the others.">
<direct-parameter description="Virtual machine owning the snapshot." type="virtual machine"/> <direct-parameter description="Snapshot to overwrite." type="snapshot"/>
<parameter name="named" code="SnNm" description="The name of the snapshot to delete." type="text"> </command>
<cocoa key="snapshotName"/>
</parameter> <command name="scan snapshots" code="UTMvscsn" description="Look through the disk images of a virtual machine for snapshots that UTM does not know about yet, and mark the ones whose data has gone missing. Only needed for snapshots made by other tools.">
<direct-parameter description="Virtual machine to scan." type="virtual machine"/>
</command>
<command name="discard saved state" code="UTMvdcss" description="Throw away the state a suspended virtual machine would resume from, so that it starts up normally instead. Snapshots are not affected.">
<direct-parameter description="Virtual machine to discard the saved state of." type="virtual machine"/>
</command> </command>
<command name="delete" code="coredelo" description="Delete a virtual machine. All data will be deleted, there is no confirmation!"> <command name="delete" code="coredelo" description="Delete a virtual machine. All data will be deleted, there is no confirmation!">
@@ -208,6 +208,11 @@
description="Serial ports exposed by the guest to the host to access."> description="Serial ports exposed by the guest to the host to access.">
<cocoa key="serialPorts"/> <cocoa key="serialPorts"/>
</element> </element>
<element type="snapshot" access="r"
description="Saved states of this virtual machine, newest first.">
<cocoa key="snapshots"/>
</element>
<responds-to command="start"> <responds-to command="start">
<cocoa method="start:"/> <cocoa method="start:"/>
@@ -233,16 +238,12 @@
<cocoa method="createSnapshot:"/> <cocoa method="createSnapshot:"/>
</responds-to> </responds-to>
<responds-to command="list snapshots"> <responds-to command="scan snapshots">
<cocoa method="listSnapshots:"/> <cocoa method="scanSnapshots:"/>
</responds-to> </responds-to>
<responds-to command="restore snapshot"> <responds-to command="discard saved state">
<cocoa method="restoreSnapshot:"/> <cocoa method="discardSavedState:"/>
</responds-to>
<responds-to command="delete snapshot">
<cocoa method="deleteSnapshot:"/>
</responds-to> </responds-to>
<responds-to command="delete"> <responds-to command="delete">
@@ -251,6 +252,45 @@
</class> </class>
<class name="snapshot" code="SnAp" description="A saved state of a virtual machine that it can be returned to." plural="snapshots">
<cocoa class="UTMScriptingSnapshotImpl"/>
<property name="id" code="ID " type="text" access="r"
description="The unique identifier of the snapshot."/>
<property name="name" code="pnam" type="text"
description="Name shown for the snapshot. Setting it renames the snapshot; a name already in use gets a number appended."/>
<property name="creation date" code="ascd" type="date" access="r"
description="When the snapshot was first saved."/>
<property name="modification date" code="asmo" type="date" access="r"
description="When the snapshot was last renamed or overwritten."/>
<property name="size" code="SnSz" type="real" access="r"
description="Bytes the snapshot occupies."/>
<property name="includes running state" code="SnRs" type="boolean" access="r"
description="Whether the snapshot holds the running machine as well as the disks. Restoring one that does not means the virtual machine starts up instead of resuming."/>
<property name="data missing" code="SnDm" type="boolean" access="r"
description="Whether the data behind the snapshot has gone missing, in which case it can only be deleted."/>
<property name="based on" code="SnBo" type="text" access="r"
description="Identifier of the snapshot the current state was based on when this one was saved, or empty if it was not based on any."/>
<responds-to command="restore">
<cocoa method="restore:"/>
</responds-to>
<responds-to command="overwrite">
<cocoa method="overwrite:"/>
</responds-to>
<responds-to command="delete snapshot">
<cocoa method="deleteSnapshot:"/>
</responds-to>
</class>
<class name="serial port" code="SeRi" description="A serial port in the guest that can be connected to from the host." plural="serial ports"> <class name="serial port" code="SeRi" description="A serial port in the guest that can be connected to from the host." plural="serial ports">
<cocoa class="UTMScriptingSerialPortImpl"/> <cocoa class="UTMScriptingSerialPortImpl"/>
<property name="id" code="ID " type="integer" access="r" <property name="id" code="ID " type="integer" access="r"
+22 -4
View File
@@ -21,6 +21,7 @@ public enum UTMScripting: String {
case guestFile = "guest file" case guestFile = "guest file"
case guestProcess = "guest process" case guestProcess = "guest process"
case serialPort = "serial port" case serialPort = "serial port"
case snapshot = "snapshot"
case usbDevice = "usb device" case usbDevice = "usb device"
case virtualMachine = "virtual machine" case virtualMachine = "virtual machine"
} }
@@ -231,6 +232,7 @@ extension SBObject: UTMScriptingWindow {}
// MARK: UTMScriptingVirtualMachine // MARK: UTMScriptingVirtualMachine
@objc public protocol UTMScriptingVirtualMachine: SBObjectProtocol, UTMScriptingGenericMethods { @objc public protocol UTMScriptingVirtualMachine: SBObjectProtocol, UTMScriptingGenericMethods {
@objc optional func serialPorts() -> SBElementArray @objc optional func serialPorts() -> SBElementArray
@objc optional func snapshots() -> SBElementArray
@objc optional func id() -> String // The unique identifier of the VM. @objc optional func id() -> String // The unique identifier of the VM.
@objc optional var name: String { get } // The name of the VM. @objc optional var name: String { get } // The name of the VM.
@objc optional var backend: UTMScriptingBackend { get } // Emulation/virtualization engine used. @objc optional var backend: UTMScriptingBackend { get } // Emulation/virtualization engine used.
@@ -240,10 +242,9 @@ extension SBObject: UTMScriptingWindow {}
@objc optional func queryInstall() -> [AnyHashable : Any] // Query the progress of the last installation started with install. Raises an error if the installation failed. @objc optional func queryInstall() -> [AnyHashable : Any] // Query the progress of the last installation started with install. Raises an error if the installation failed.
@objc optional func suspendSaving(_ saving: Bool) // Suspend a running virtual machine to memory. @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 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 createSnapshotNamed(_ named: String!) -> UTMScriptingSnapshot // Save the current state of a virtual machine as a new snapshot. A running QEMU virtual machine is saved along with its running state, otherwise only the disks are.
@objc optional func listSnapshots() -> [Any] // List the names of all named snapshots for a stopped QEMU virtual machine, newest first. @objc optional func scanSnapshots() // Look through the disk images of a virtual machine for snapshots that UTM does not know about yet, and mark the ones whose data has gone missing. Only needed for snapshots made by other tools.
@objc optional func restoreSnapshotNamed(_ named: String!) // Restore a stopped QEMU virtual machine to a named snapshot. @objc optional func discardSavedState() // Throw away the state a suspended virtual machine would resume from, so that it starts up normally instead. Snapshots are not affected.
@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 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 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. @objc optional func exportTo(_ to: URL!) // Export a virtual machine to a specified location.
@@ -263,6 +264,23 @@ extension SBObject: UTMScriptingWindow {}
} }
extension SBObject: UTMScriptingVirtualMachine {} extension SBObject: UTMScriptingVirtualMachine {}
// MARK: UTMScriptingSnapshot
@objc public protocol UTMScriptingSnapshot: SBObjectProtocol, UTMScriptingGenericMethods {
@objc optional func id() -> String // The unique identifier of the snapshot.
@objc optional var name: String { get } // Name shown for the snapshot. Setting it renames the snapshot; a name already in use gets a number appended.
@objc optional var creationDate: Date { get } // When the snapshot was first saved.
@objc optional var modificationDate: Date { get } // When the snapshot was last renamed or overwritten.
@objc optional var size: Double { get } // Bytes the snapshot occupies.
@objc optional var includesRunningState: Bool { get } // Whether the snapshot holds the running machine as well as the disks. Restoring one that does not means the virtual machine starts up instead of resuming.
@objc optional var dataMissing: Bool { get } // Whether the data behind the snapshot has gone missing, in which case it can only be deleted.
@objc optional var basedOn: String { get } // Identifier of the snapshot the current state was based on when this one was saved, or empty if it was not based on any.
@objc optional func restore() // Replace the current state of a virtual machine with a snapshot. A virtual machine that is not running resumes from it the next time it is started.
@objc optional func deleteSnapshot() // Delete a snapshot. Its data is deleted with it, there is no confirmation!
@objc optional func overwrite() // Replace what a snapshot holds with the current state of the virtual machine. The snapshot keeps its name and its place among the others.
@objc optional func setName(_ name: String!) // Name shown for the snapshot. Setting it renames the snapshot; a name already in use gets a number appended.
}
extension SBObject: UTMScriptingSnapshot {}
// MARK: UTMScriptingSerialPort // MARK: UTMScriptingSerialPort
@objc public protocol UTMScriptingSerialPort: SBObjectProtocol, UTMScriptingGenericMethods { @objc public protocol UTMScriptingSerialPort: SBObjectProtocol, UTMScriptingGenericMethods {
@objc optional func id() -> Int // The unique identifier of the tag. @objc optional func id() -> Int // The unique identifier of the tag.
@@ -20,6 +20,12 @@ import Foundation
@objc(UTMScriptingDeleteCommand) @objc(UTMScriptingDeleteCommand)
class UTMScriptingDeleteCommand: NSDeleteCommand, UTMScriptable { class UTMScriptingDeleteCommand: NSDeleteCommand, UTMScriptable {
override func performDefaultImplementation() -> Any? { override func performDefaultImplementation() -> Any? {
// a specifier for something inside a virtual machine, such as one of its snapshots, can
// be coerced into the virtual machine itself, and deleting that would throw away far
// more than was asked for
guard keySpecifier.key == "scriptingVirtualMachines" else {
return super.performDefaultImplementation()
}
if let scriptingVM = keySpecifier.objectsByEvaluatingSpecifier as? UTMScriptingVirtualMachineImpl { if let scriptingVM = keySpecifier.objectsByEvaluatingSpecifier as? UTMScriptingVirtualMachineImpl {
scriptingVM.delete(self) scriptingVM.delete(self)
return nil return nil
+114
View File
@@ -0,0 +1,114 @@
//
// 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
@MainActor
@objc(UTMScriptingSnapshotImpl)
class UTMScriptingSnapshotImpl: NSObject, UTMScriptable {
/// Read when the object is resolved, which happens anew for every event
private var snapshot: UTMSnapshot
/// Held strongly because a command runs after the accessor that made this object has returned
@objc private(set) var parent: UTMScriptingVirtualMachineImpl
private var vm: (any UTMVirtualMachine)! {
parent.vm
}
init(snapshot: UTMSnapshot, parent: UTMScriptingVirtualMachineImpl) {
self.snapshot = snapshot
self.parent = parent
}
@objc var id: String {
snapshot.id.uuidString
}
@objc var name: String {
get {
snapshot.title
}
set {
do {
snapshot = try UTMSnapshotService.renameSnapshot(snapshot.id, to: newValue, on: vm)
} catch {
let command = NSScriptCommand.current()
command?.scriptErrorNumber = errOSAGeneralError
command?.scriptErrorString = error.localizedDescription
}
}
}
@objc var creationDate: Date {
snapshot.dateCreated
}
@objc var modificationDate: Date {
snapshot.dateModified
}
@objc var size: Int {
Int(snapshot.size)
}
@objc var includesRunningState: Bool {
snapshot.hasState
}
@objc var isDataMissing: Bool {
snapshot.isOrphaned
}
/// Identifier of the snapshot this one was based on, empty if it was not based on any
@objc var basedOn: String {
snapshot.parentID?.uuidString ?? ""
}
override var objectSpecifier: NSScriptObjectSpecifier? {
guard let parentDescription = parent.classDescription as? NSScriptClassDescription else {
return nil
}
return NSUniqueIDSpecifier(containerClassDescription: parentDescription,
containerSpecifier: parent.objectSpecifier,
key: "snapshots",
uniqueID: id)
}
// MARK: - Commands
@objc func restore(_ command: NSScriptCommand) {
withScriptCommand(command) { [self] in
try await UTMSnapshotService.restoreSnapshot(snapshot.id, on: vm)
}
}
@objc func overwrite(_ command: NSScriptCommand) {
withScriptCommand(command) { [self] in
try await UTMSnapshotService.overwriteSnapshot(snapshot.id, on: vm)
}
}
@objc func deleteSnapshot(_ command: NSScriptCommand) {
withScriptCommand(command) { [self] in
try await UTMSnapshotService.deleteSnapshot(snapshot.id, on: vm)
}
}
static func == (lhs: UTMScriptingSnapshotImpl, rhs: UTMScriptingSnapshotImpl) -> Bool {
lhs.snapshot.id == rhs.snapshot.id && lhs.parent == rhs.parent
}
}
+30 -21
View File
@@ -91,6 +91,28 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
} }
} }
@objc var snapshots: [UTMScriptingSnapshotImpl] {
UTMSnapshotService.snapshots(for: vm).map { UTMScriptingSnapshotImpl(snapshot: $0, parent: self) }
}
/// Resolve `snapshot id "..." of virtual machine ...`
///
/// Without this a specifier for a snapshot cannot be evaluated and falls back to this virtual
/// machine, which would aim a command meant for one snapshot at the whole VM.
@objc(valueInSnapshotsWithUniqueID:)
func valueInSnapshots(withUniqueID id: Any) -> UTMScriptingSnapshotImpl? {
guard let id = id as? String else {
return nil
}
return snapshots.first { $0.id == id }
}
/// Resolve `snapshot "..." of virtual machine ...`
@objc(valueInSnapshotsWithName:)
func valueInSnapshots(withName name: String) -> UTMScriptingSnapshotImpl? {
snapshots.first { $0.name == name }
}
var guestAgent: QEMUGuestAgent! { var guestAgent: QEMUGuestAgent! {
get async { get async {
await (vm as? UTMQemuVirtualMachine)?.guestAgent await (vm as? UTMQemuVirtualMachine)?.guestAgent
@@ -290,36 +312,23 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
@objc func createSnapshot(_ command: NSScriptCommand) { @objc func createSnapshot(_ command: NSScriptCommand) {
let name = command.evaluatedArguments?["snapshotName"] as? String let name = command.evaluatedArguments?["snapshotName"] as? String
withScriptCommand(command) { [self] in withScriptCommand(command) { [self] in
guard let name = name else { let snapshot = try await UTMSnapshotService.createSnapshot(title: name, on: vm)
throw ScriptingError.invalidParameter return UTMScriptingSnapshotImpl(snapshot: snapshot, parent: self)
}
try await UTMSnapshotService.createSnapshot(name: name, on: vm)
} }
} }
@objc func listSnapshots(_ command: NSScriptCommand) { @objc func scanSnapshots(_ command: NSScriptCommand) {
withScriptCommand(command) { [self] in withScriptCommand(command) { [self] in
return try await UTMSnapshotService.listSnapshots(on: vm) try await UTMSnapshotService.scanSnapshots(on: vm)
} }
} }
@objc func restoreSnapshot(_ command: NSScriptCommand) { @objc func discardSavedState(_ command: NSScriptCommand) {
let name = command.evaluatedArguments?["snapshotName"] as? String
withScriptCommand(command) { [self] in withScriptCommand(command) { [self] in
guard let name = name else { guard vm.registryEntry.isSuspended else {
throw ScriptingError.invalidParameter return
} }
try await UTMSnapshotService.restoreSnapshot(name: name, on: vm) try await vm.deleteSnapshot(name: nil)
}
}
@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)
} }
} }
+23 -32
View File
@@ -63,6 +63,11 @@ enum UTMSnapshotService {
isCurrentStateSaved: isCurrentStateSaved(in: manifest, on: vm)) isCurrentStateSaved: isCurrentStateSaved(in: manifest, on: vm))
} }
/// Snapshots of a VM as they are recorded in the bundle, newest first, without consulting the backend
static func snapshots(for vm: any UTMVirtualMachine) -> [UTMSnapshot] {
(((try? UTMSnapshotManifest.load(from: vm.pathUrl)) ?? nil)?.snapshots ?? []).map { UTMSnapshot($0, on: vm) }
}
/// An operation is possible while the VM is in its current state. /// An operation is possible while the VM is in its current state.
/// ///
/// Snapshots can be managed whether the VM is running or not, but not everything can be done /// Snapshots can be managed whether the VM is running or not, but not everything can be done
@@ -125,7 +130,7 @@ enum UTMSnapshotService {
// the VM still resumes from the state which is now the one of the snapshot // the VM still resumes from the state which is now the one of the snapshot
manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier) manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier)
} }
let snapshot = Entry(title: title ?? manifest.defaultTitle, let snapshot = Entry(title: title.map { manifest.uniqueTitle($0) } ?? manifest.defaultTitle,
parentID: manifest.currentParentID, parentID: manifest.currentParentID,
date: entry.date, date: entry.date,
size: entry.size, size: entry.size,
@@ -194,7 +199,8 @@ enum UTMSnapshotService {
guard !title.isEmpty && snapshot.title != title else { guard !title.isEmpty && snapshot.title != title else {
return snapshot return snapshot
} }
snapshot.title = title // a snapshot is addressed by its title outside of the app, so keep them apart
snapshot.title = manifest.uniqueTitle(title, excluding: id)
snapshot.dateModified = Date() snapshot.dateModified = Date()
manifest[id] = snapshot manifest[id] = snapshot
return snapshot return snapshot
@@ -332,36 +338,20 @@ enum UTMSnapshotService {
} }
} }
// MARK: - Snapshots by name // MARK: - Reconciling with the backend
/// Create a snapshot or overwrite the one with the same title. /// Bring the manifest in line with what the backend holds.
static func createSnapshot(name: String, on vm: any UTMVirtualMachine) async throws { ///
if let snapshot = try await timeline(for: vm).snapshots.first(where: { $0.title == name }) { /// Snapshots made by other tools are added and snapshots whose state has disappeared are
try await overwriteSnapshot(snapshot.id, on: vm) /// marked. The snapshots tab does this the first time a VM is opened; everywhere else it has
} else { /// to be asked for, because it reads every image.
try await createSnapshot(title: name, on: vm) static func scanSnapshots(on vm: any UTMVirtualMachine) async throws {
let backend = try backend(for: vm)
guard backend.isInventoryExact else {
throw UTMSnapshotError.requiresStoppedVm
} }
} try await importUnlinkedSnapshots(from: backend, on: vm)
try await identifyOrphanedSnapshots(on: vm)
/// 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 }
}
static func restoreSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
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)
}
return snapshot
} }
// MARK: - Helpers // MARK: - Helpers
@@ -539,10 +529,11 @@ private extension UTMSnapshotManifest {
return title return title
} }
func uniqueTitle(_ base: String) -> String { /// - Parameter id: Snapshot that may keep the title it already has
func uniqueTitle(_ base: String, excluding id: UUID? = nil) -> String {
var title = base var title = base
var number = 2 var number = 2
while snapshots.contains(where: { $0.title == title }) { while snapshots.contains(where: { $0.title == title && $0.id != id }) {
title = "\(base) \(number)" title = "\(base) \(number)"
number += 1 number += 1
} }
+4
View File
@@ -974,6 +974,7 @@
CEC1B00C2BBB211C0088119D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */; }; CEC1B00C2BBB211C0088119D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */; };
CEC1B00D2BBB211C0088119D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */; }; CEC1B00D2BBB211C0088119D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */; };
CEC794BA294924E300121A9F /* UTMScriptingSerialPortImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */; }; CEC794BA294924E300121A9F /* UTMScriptingSerialPortImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */; };
FC6D2EA0328DA5CD033B9518 /* UTMScriptingSnapshotImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */; };
CEC794BC2949663C00121A9F /* UTMScripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794BB2949663C00121A9F /* UTMScripting.swift */; }; CEC794BC2949663C00121A9F /* UTMScripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794BB2949663C00121A9F /* UTMScripting.swift */; };
CEC794BD2949663C00121A9F /* UTMScripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794BB2949663C00121A9F /* UTMScripting.swift */; }; CEC794BD2949663C00121A9F /* UTMScripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC794BB2949663C00121A9F /* UTMScripting.swift */; };
CED234ED254796E500ED0A57 /* NumberTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED234EC254796E500ED0A57 /* NumberTextField.swift */; }; CED234ED254796E500ED0A57 /* NumberTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED234EC254796E500ED0A57 /* NumberTextField.swift */; };
@@ -2167,6 +2168,7 @@
CEC0A3092A7490D200980857 /* VMConfigQEMUArgumentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigQEMUArgumentsView.swift; sourceTree = "<group>"; }; CEC0A3092A7490D200980857 /* VMConfigQEMUArgumentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigQEMUArgumentsView.swift; sourceTree = "<group>"; };
CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingSerialPortImpl.swift; sourceTree = "<group>"; }; CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingSerialPortImpl.swift; sourceTree = "<group>"; };
38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingSnapshotImpl.swift; sourceTree = "<group>"; };
CEC794BB2949663C00121A9F /* UTMScripting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UTMScripting.swift; sourceTree = "<group>"; }; CEC794BB2949663C00121A9F /* UTMScripting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UTMScripting.swift; sourceTree = "<group>"; };
CEC9968328AA516000E7A025 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; CEC9968328AA516000E7A025 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
CECF02562B706ADD00409FC0 /* UTMRemoteConnectInterface.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMRemoteConnectInterface.h; sourceTree = "<group>"; }; CECF02562B706ADD00409FC0 /* UTMRemoteConnectInterface.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UTMRemoteConnectInterface.h; sourceTree = "<group>"; };
@@ -3225,6 +3227,7 @@
CE59A7AF2AA69AE400E5FFBD /* UTMScriptingUSBDeviceImpl.swift */, CE59A7AF2AA69AE400E5FFBD /* UTMScriptingUSBDeviceImpl.swift */,
CEFE98E029485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift */, CEFE98E029485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift */,
CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */, CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */,
38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */,
CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */, CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */,
CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */, CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */,
CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */, CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */,
@@ -4064,6 +4067,7 @@
CE9375A224BBDDD10074066F /* VMConfigDriveDetailsView.swift in Sources */, CE9375A224BBDDD10074066F /* VMConfigDriveDetailsView.swift in Sources */,
CE2D955824AD4F980059923A /* VMConfigDisplayView.swift in Sources */, CE2D955824AD4F980059923A /* VMConfigDisplayView.swift in Sources */,
CEC794BA294924E300121A9F /* UTMScriptingSerialPortImpl.swift in Sources */, CEC794BA294924E300121A9F /* UTMScriptingSerialPortImpl.swift in Sources */,
FC6D2EA0328DA5CD033B9518 /* UTMScriptingSnapshotImpl.swift in Sources */,
CE03D05324D90B4E00F76B84 /* UTMQemuSystem.m in Sources */, CE03D05324D90B4E00F76B84 /* UTMQemuSystem.m in Sources */,
CE88A1602E24B2B400EAA28E /* UTMInputIntent.swift in Sources */, CE88A1602E24B2B400EAA28E /* UTMInputIntent.swift in Sources */,
841619B82843226B000034B2 /* QEMUConstant.swift in Sources */, 841619B82843226B000034B2 /* QEMUConstant.swift in Sources */,
+169 -33
View File
@@ -37,6 +37,7 @@ struct UTMCtl: ParsableCommand {
IPAddress.self, IPAddress.self,
Clone.self, Clone.self,
Delete.self, Delete.self,
DiscardState.self,
USB.self, USB.self,
Snapshot.self Snapshot.self
] ]
@@ -91,6 +92,29 @@ extension UTMAPICommand {
} }
} }
/// Snapshots of a virtual machine, newest first
/// - Parameter vm: Virtual machine
/// - Returns: Snapshots of the virtual machine
func snapshots(of vm: UTMScriptingVirtualMachine) -> [UTMScriptingSnapshot] {
(vm.snapshots!() as? [UTMScriptingSnapshot]) ?? []
}
/// Get a snapshot of a virtual machine from an identifier
/// - Parameters:
/// - identifier: Name or identifier of the snapshot
/// - vm: Virtual machine the snapshot belongs to
/// - Returns: Snapshot for identifier
func snapshot(forIdentifier identifier: UTMCtl.SnapshotIdentifier, of vm: UTMScriptingVirtualMachine) throws -> UTMScriptingSnapshot {
let snapshots = snapshots(of: vm)
if let snapshot = snapshots.first(where: { $0.id!() == identifier.snapshot }) {
return snapshot
} else if let snapshot = snapshots.first(where: { $0.name! == identifier.snapshot }) {
return snapshot
} else {
throw UTMCtl.APIError.snapshotNotFound
}
}
/// Find the path to UTM.app /// Find the path to UTM.app
private var utmAppUrl: URL { private var utmAppUrl: URL {
if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() { if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() {
@@ -114,6 +138,9 @@ extension UTMAPICommand {
extension UTMCtl { extension UTMCtl {
@objc class EventErrorHandler: NSObject, SBApplicationDelegate { @objc class EventErrorHandler: NSObject, SBApplicationDelegate {
static let shared = EventErrorHandler() static let shared = EventErrorHandler()
/// An event failed, so what it returned must not be used
private(set) var hasFailed = false
/// Error handler for scripting events /// Error handler for scripting events
/// - Parameters: /// - Parameters:
@@ -121,6 +148,7 @@ extension UTMCtl {
/// - error: Error /// - error: Error
/// - Returns: nil /// - Returns: nil
func eventDidFail(_ event: UnsafePointer<AppleEvent>, withError error: Error) -> Any? { func eventDidFail(_ event: UnsafePointer<AppleEvent>, withError error: Error) -> Any? {
hasFailed = true
let error = error as NSError let error = error as NSError
FileHandle.standardError.write("Error from event: \(error.localizedDescription)") FileHandle.standardError.write("Error from event: \(error.localizedDescription)")
if let user = error.userInfo["ErrorString"] as? String { if let user = error.userInfo["ErrorString"] as? String {
@@ -140,11 +168,13 @@ extension UTMCtl {
case virtualMachineNotFound case virtualMachineNotFound
case invalidIdentifier(String) case invalidIdentifier(String)
case deviceNotFound case deviceNotFound
case snapshotNotFound
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .applicationNotFound: return "Application not found." case .applicationNotFound: return "Application not found."
case .virtualMachineNotFound: return "Virtual machine not found." case .virtualMachineNotFound: return "Virtual machine not found."
case .snapshotNotFound: return "Snapshot not found."
case .invalidIdentifier(let identifier): return "Identifier '\(identifier)' is invalid." case .invalidIdentifier(let identifier): return "Identifier '\(identifier)' is invalid."
case .deviceNotFound: return "Device not found." case .deviceNotFound: return "Device not found."
} }
@@ -741,92 +771,198 @@ extension UTMCtl {
extension UTMCtl { extension UTMCtl {
struct Snapshot: ParsableCommand { struct Snapshot: ParsableCommand {
static var configuration = CommandConfiguration( static var configuration = CommandConfiguration(
abstract: "Create, list, restore, and delete named full-VM snapshots for QEMU virtual machines.", abstract: "List and manage the saved states of a virtual machine.",
subcommands: [SnapshotCreate.self, SnapshotList.self, SnapshotRestore.self, SnapshotDelete.self] subcommands: [SnapshotList.self, SnapshotCreate.self, SnapshotRestore.self, SnapshotOverwrite.self, SnapshotRename.self, SnapshotDelete.self, SnapshotScan.self]
) )
} }
struct SnapshotCreate: UTMAPICommand { /// Addresses one snapshot of a virtual machine
static var configuration = CommandConfiguration( struct SnapshotIdentifier: ParsableArguments {
commandName: "create", @Option(name: .shortAndLong, help: "Either the name or the identifier of the snapshot.")
abstract: "Create or replace a named snapshot of a running or paused QEMU virtual machine." var snapshot: String
)
@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 { struct SnapshotList: UTMAPICommand {
static var configuration = CommandConfiguration( static var configuration = CommandConfiguration(
commandName: "list", commandName: "list",
abstract: "List the names of all snapshots for a stopped QEMU virtual machine." abstract: "List the snapshots of a virtual machine, newest first."
) )
@OptionGroup var environment: EnvironmentOptions @OptionGroup var environment: EnvironmentOptions
@OptionGroup var identifer: VMIdentifier @OptionGroup var identifer: VMIdentifier
@Flag(help: "Output the list as a JSON array.") @Flag(help: "Output the list as JSON.")
var json: Bool = false var json: Bool = false
@Flag(help: "Look for snapshots made by other tools before listing.")
var scan: Bool = false
func run(with application: UTMScriptingApplication) throws { func run(with application: UTMScriptingApplication) throws {
let vm = try virtualMachine(forIdentifier: identifer, in: application) let vm = try virtualMachine(forIdentifier: identifer, in: application)
let names = (vm.listSnapshots!() as? [String]) ?? [] if scan {
vm.scanSnapshots!()
}
let snapshots = snapshots(of: vm)
if json { if json {
let data = try JSONSerialization.data(withJSONObject: names, options: [.prettyPrinted]) let rows = snapshots.map { snapshot -> [String: Any] in
["id": snapshot.id!(),
"name": snapshot.name!,
"created": ISO8601DateFormatter().string(from: snapshot.creationDate!),
"modified": ISO8601DateFormatter().string(from: snapshot.modificationDate!),
"size": snapshot.size!,
"includesRunningState": snapshot.includesRunningState!,
"dataMissing": snapshot.dataMissing!,
"basedOn": snapshot.basedOn!]
}
let data = try JSONSerialization.data(withJSONObject: rows, options: [.prettyPrinted, .sortedKeys])
print(String(data: data, encoding: .utf8) ?? "[]") print(String(data: data, encoding: .utf8) ?? "[]")
} else { } else {
for name in names { let formatter = DateFormatter()
print(name) formatter.dateStyle = .short
formatter.timeStyle = .short
for snapshot in snapshots {
var notes = [snapshot.includesRunningState! ? "resumes" : "disks only"]
if snapshot.dataMissing! {
notes.append("data missing")
}
let size = snapshot.size! > 0 ? ByteCountFormatter.string(fromByteCount: Int64(snapshot.size!), countStyle: .binary) : "-"
print("\(snapshot.name!)\t\(formatter.string(from: snapshot.creationDate!))\t\(size)\t\(notes.joined(separator: ", "))")
} }
} }
} }
} }
struct SnapshotCreate: UTMAPICommand {
static var configuration = CommandConfiguration(
commandName: "create",
abstract: "Save the current state of a virtual machine as a new snapshot."
)
@OptionGroup var environment: EnvironmentOptions
@OptionGroup var identifer: VMIdentifier
@Option(help: "Name for the new snapshot. A name already in use gets a number appended.")
var name: String?
func run(with application: UTMScriptingApplication) throws {
let vm = try virtualMachine(forIdentifier: identifer, in: application)
let snapshot = vm.createSnapshotNamed!(name)
// nothing is returned when the event failed, which has been reported already
guard !EventErrorHandler.shared.hasFailed else {
throw ExitCode.failure
}
print(snapshot.name!)
}
}
struct SnapshotRestore: UTMAPICommand { struct SnapshotRestore: UTMAPICommand {
static var configuration = CommandConfiguration( static var configuration = CommandConfiguration(
commandName: "restore", commandName: "restore",
abstract: "Restore a stopped QEMU virtual machine to a named snapshot." abstract: "Replace the current state of a virtual machine with a snapshot."
) )
@OptionGroup var environment: EnvironmentOptions @OptionGroup var environment: EnvironmentOptions
@OptionGroup var identifer: VMIdentifier @OptionGroup var identifer: VMIdentifier
@Option(help: "Name of the snapshot to restore.") @OptionGroup var snapshotIdentifer: SnapshotIdentifier
var name: String
func run(with application: UTMScriptingApplication) throws { func run(with application: UTMScriptingApplication) throws {
let vm = try virtualMachine(forIdentifier: identifer, in: application) let vm = try virtualMachine(forIdentifier: identifer, in: application)
vm.restoreSnapshotNamed!(name) try snapshot(forIdentifier: snapshotIdentifer, of: vm).restore!()
}
}
struct SnapshotOverwrite: UTMAPICommand {
static var configuration = CommandConfiguration(
commandName: "overwrite",
abstract: "Replace what a snapshot holds with the current state of the virtual machine."
)
@OptionGroup var environment: EnvironmentOptions
@OptionGroup var identifer: VMIdentifier
@OptionGroup var snapshotIdentifer: SnapshotIdentifier
func run(with application: UTMScriptingApplication) throws {
let vm = try virtualMachine(forIdentifier: identifer, in: application)
try snapshot(forIdentifier: snapshotIdentifer, of: vm).overwrite!()
}
}
struct SnapshotRename: UTMAPICommand {
static var configuration = CommandConfiguration(
commandName: "rename",
abstract: "Change the name shown for a snapshot."
)
@OptionGroup var environment: EnvironmentOptions
@OptionGroup var identifer: VMIdentifier
@OptionGroup var snapshotIdentifer: SnapshotIdentifier
@Option(help: "New name for the snapshot.")
var to: String
func run(with application: UTMScriptingApplication) throws {
let vm = try virtualMachine(forIdentifier: identifer, in: application)
let snapshot = try snapshot(forIdentifier: snapshotIdentifer, of: vm)
snapshot.setName!(to)
print(snapshot.name!)
} }
} }
struct SnapshotDelete: UTMAPICommand { struct SnapshotDelete: UTMAPICommand {
static var configuration = CommandConfiguration( static var configuration = CommandConfiguration(
commandName: "delete", commandName: "delete",
abstract: "Delete a named snapshot from a QEMU virtual machine." abstract: "Delete a snapshot of a virtual machine."
) )
@OptionGroup var environment: EnvironmentOptions @OptionGroup var environment: EnvironmentOptions
@OptionGroup var identifer: VMIdentifier @OptionGroup var identifer: VMIdentifier
@Option(help: "Name of the snapshot to delete.") @OptionGroup var snapshotIdentifer: SnapshotIdentifier
var name: String
func run(with application: UTMScriptingApplication) throws { func run(with application: UTMScriptingApplication) throws {
let vm = try virtualMachine(forIdentifier: identifer, in: application) let vm = try virtualMachine(forIdentifier: identifer, in: application)
vm.deleteSnapshotNamed!(name) try snapshot(forIdentifier: snapshotIdentifer, of: vm).deleteSnapshot!()
}
}
struct SnapshotScan: UTMAPICommand {
static var configuration = CommandConfiguration(
commandName: "scan",
abstract: "Look through the disk images for snapshots made by other tools and mark any whose data is missing."
)
@OptionGroup var environment: EnvironmentOptions
@OptionGroup var identifer: VMIdentifier
func run(with application: UTMScriptingApplication) throws {
let vm = try virtualMachine(forIdentifier: identifer, in: application)
vm.scanSnapshots!()
}
}
struct DiscardState: UTMAPICommand {
static var configuration = CommandConfiguration(
commandName: "discard-state",
abstract: "Discard the saved state a suspended virtual machine would resume from, so that it starts up normally."
)
@OptionGroup var environment: EnvironmentOptions
@OptionGroup var identifer: VMIdentifier
func run(with application: UTMScriptingApplication) throws {
let vm = try virtualMachine(forIdentifier: identifer, in: application)
vm.discardSavedState!()
} }
} }
} }