mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-11 21:59:56 +00:00
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:
+65
-25
@@ -127,32 +127,32 @@
|
||||
</parameter>
|
||||
</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"/>
|
||||
<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"/>
|
||||
</parameter>
|
||||
<result description="The snapshot that was created." type="snapshot"/>
|
||||
</command>
|
||||
|
||||
<command name="list snapshots" code="UTMvlssn" description="List the names of all named snapshots for a stopped QEMU virtual machine, newest first.">
|
||||
<direct-parameter description="Virtual machine to list snapshots for." type="virtual machine"/>
|
||||
<result description="List of snapshot names.">
|
||||
<type type="text" list="yes"/>
|
||||
</result>
|
||||
<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="Snapshot to restore." type="snapshot"/>
|
||||
</command>
|
||||
|
||||
<command name="restore snapshot" code="UTMvrssn" description="Restore a stopped QEMU virtual machine to a named snapshot.">
|
||||
<direct-parameter description="Virtual machine to restore." type="virtual machine"/>
|
||||
<parameter name="named" code="SnNm" description="The name of the snapshot to restore." type="text">
|
||||
<cocoa key="snapshotName"/>
|
||||
</parameter>
|
||||
<command name="delete snapshot" code="UTMvdlsn" description="Delete a snapshot. Its data is deleted with it, there is no confirmation!">
|
||||
<direct-parameter description="Snapshot to delete." type="snapshot"/>
|
||||
</command>
|
||||
|
||||
<command name="delete snapshot" code="UTMvdlsn" description="Delete a named snapshot from a QEMU virtual machine.">
|
||||
<direct-parameter description="Virtual machine owning the snapshot." type="virtual machine"/>
|
||||
<parameter name="named" code="SnNm" description="The name of the snapshot to delete." type="text">
|
||||
<cocoa key="snapshotName"/>
|
||||
</parameter>
|
||||
<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="Snapshot to overwrite." type="snapshot"/>
|
||||
</command>
|
||||
|
||||
<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 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.">
|
||||
<cocoa key="serialPorts"/>
|
||||
</element>
|
||||
|
||||
<element type="snapshot" access="r"
|
||||
description="Saved states of this virtual machine, newest first.">
|
||||
<cocoa key="snapshots"/>
|
||||
</element>
|
||||
|
||||
<responds-to command="start">
|
||||
<cocoa method="start:"/>
|
||||
@@ -233,16 +238,12 @@
|
||||
<cocoa method="createSnapshot:"/>
|
||||
</responds-to>
|
||||
|
||||
<responds-to command="list snapshots">
|
||||
<cocoa method="listSnapshots:"/>
|
||||
<responds-to command="scan snapshots">
|
||||
<cocoa method="scanSnapshots:"/>
|
||||
</responds-to>
|
||||
|
||||
<responds-to command="restore snapshot">
|
||||
<cocoa method="restoreSnapshot:"/>
|
||||
</responds-to>
|
||||
|
||||
<responds-to command="delete snapshot">
|
||||
<cocoa method="deleteSnapshot:"/>
|
||||
<responds-to command="discard saved state">
|
||||
<cocoa method="discardSavedState:"/>
|
||||
</responds-to>
|
||||
|
||||
<responds-to command="delete">
|
||||
@@ -251,6 +252,45 @@
|
||||
|
||||
</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">
|
||||
<cocoa class="UTMScriptingSerialPortImpl"/>
|
||||
<property name="id" code="ID " type="integer" access="r"
|
||||
|
||||
@@ -21,6 +21,7 @@ public enum UTMScripting: String {
|
||||
case guestFile = "guest file"
|
||||
case guestProcess = "guest process"
|
||||
case serialPort = "serial port"
|
||||
case snapshot = "snapshot"
|
||||
case usbDevice = "usb device"
|
||||
case virtualMachine = "virtual machine"
|
||||
}
|
||||
@@ -231,6 +232,7 @@ extension SBObject: UTMScriptingWindow {}
|
||||
// MARK: UTMScriptingVirtualMachine
|
||||
@objc public protocol UTMScriptingVirtualMachine: SBObjectProtocol, UTMScriptingGenericMethods {
|
||||
@objc optional func serialPorts() -> SBElementArray
|
||||
@objc optional func snapshots() -> SBElementArray
|
||||
@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 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 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 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 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 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 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.
|
||||
@@ -263,6 +264,23 @@ extension SBObject: UTMScriptingWindow {}
|
||||
}
|
||||
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
|
||||
@objc public protocol UTMScriptingSerialPort: SBObjectProtocol, UTMScriptingGenericMethods {
|
||||
@objc optional func id() -> Int // The unique identifier of the tag.
|
||||
|
||||
@@ -20,6 +20,12 @@ import Foundation
|
||||
@objc(UTMScriptingDeleteCommand)
|
||||
class UTMScriptingDeleteCommand: NSDeleteCommand, UTMScriptable {
|
||||
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 {
|
||||
scriptingVM.delete(self)
|
||||
return nil
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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! {
|
||||
get async {
|
||||
await (vm as? UTMQemuVirtualMachine)?.guestAgent
|
||||
@@ -290,36 +312,23 @@ 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)
|
||||
let snapshot = try await UTMSnapshotService.createSnapshot(title: name, on: vm)
|
||||
return UTMScriptingSnapshotImpl(snapshot: snapshot, parent: self)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func listSnapshots(_ command: NSScriptCommand) {
|
||||
@objc func scanSnapshots(_ command: NSScriptCommand) {
|
||||
withScriptCommand(command) { [self] in
|
||||
return try await UTMSnapshotService.listSnapshots(on: vm)
|
||||
try await UTMSnapshotService.scanSnapshots(on: vm)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func restoreSnapshot(_ command: NSScriptCommand) {
|
||||
let name = command.evaluatedArguments?["snapshotName"] as? String
|
||||
@objc func discardSavedState(_ command: NSScriptCommand) {
|
||||
withScriptCommand(command) { [self] in
|
||||
guard let name = name else {
|
||||
throw ScriptingError.invalidParameter
|
||||
guard vm.registryEntry.isSuspended else {
|
||||
return
|
||||
}
|
||||
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)
|
||||
try await vm.deleteSnapshot(name: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ enum UTMSnapshotService {
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
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,
|
||||
date: entry.date,
|
||||
size: entry.size,
|
||||
@@ -194,7 +199,8 @@ enum UTMSnapshotService {
|
||||
guard !title.isEmpty && snapshot.title != title else {
|
||||
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()
|
||||
manifest[id] = 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.
|
||||
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 }) {
|
||||
try await overwriteSnapshot(snapshot.id, on: vm)
|
||||
} else {
|
||||
try await createSnapshot(title: name, on: vm)
|
||||
/// Bring the manifest in line with what the backend holds.
|
||||
///
|
||||
/// Snapshots made by other tools are added and snapshots whose state has disappeared are
|
||||
/// marked. The snapshots tab does this the first time a VM is opened; everywhere else it has
|
||||
/// to be asked for, because it reads every image.
|
||||
static func scanSnapshots(on vm: any UTMVirtualMachine) async throws {
|
||||
let backend = try backend(for: vm)
|
||||
guard backend.isInventoryExact else {
|
||||
throw UTMSnapshotError.requiresStoppedVm
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
try await importUnlinkedSnapshots(from: backend, on: vm)
|
||||
try await identifyOrphanedSnapshots(on: vm)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
@@ -539,10 +529,11 @@ private extension UTMSnapshotManifest {
|
||||
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 number = 2
|
||||
while snapshots.contains(where: { $0.title == title }) {
|
||||
while snapshots.contains(where: { $0.title == title && $0.id != id }) {
|
||||
title = "\(base) \(number)"
|
||||
number += 1
|
||||
}
|
||||
|
||||
@@ -974,6 +974,7 @@
|
||||
CEC1B00C2BBB211C0088119D /* 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 */; };
|
||||
FC6D2EA0328DA5CD033B9518 /* UTMScriptingSnapshotImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */; };
|
||||
CEC794BC2949663C00121A9F /* 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 */; };
|
||||
@@ -2167,6 +2168,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -3225,6 +3227,7 @@
|
||||
CE59A7AF2AA69AE400E5FFBD /* UTMScriptingUSBDeviceImpl.swift */,
|
||||
CEFE98E029485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift */,
|
||||
CEC794B9294924E300121A9F /* UTMScriptingSerialPortImpl.swift */,
|
||||
38E2C5FE357F6FEDAEA675CA /* UTMScriptingSnapshotImpl.swift */,
|
||||
CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */,
|
||||
CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */,
|
||||
CE6804842E4E5D84001671E9 /* UTMScriptingInputImpl.swift */,
|
||||
@@ -4064,6 +4067,7 @@
|
||||
CE9375A224BBDDD10074066F /* VMConfigDriveDetailsView.swift in Sources */,
|
||||
CE2D955824AD4F980059923A /* VMConfigDisplayView.swift in Sources */,
|
||||
CEC794BA294924E300121A9F /* UTMScriptingSerialPortImpl.swift in Sources */,
|
||||
FC6D2EA0328DA5CD033B9518 /* UTMScriptingSnapshotImpl.swift in Sources */,
|
||||
CE03D05324D90B4E00F76B84 /* UTMQemuSystem.m in Sources */,
|
||||
CE88A1602E24B2B400EAA28E /* UTMInputIntent.swift in Sources */,
|
||||
841619B82843226B000034B2 /* QEMUConstant.swift in Sources */,
|
||||
|
||||
+169
-33
@@ -37,6 +37,7 @@ struct UTMCtl: ParsableCommand {
|
||||
IPAddress.self,
|
||||
Clone.self,
|
||||
Delete.self,
|
||||
DiscardState.self,
|
||||
USB.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
|
||||
private var utmAppUrl: URL {
|
||||
if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() {
|
||||
@@ -114,6 +138,9 @@ extension UTMAPICommand {
|
||||
extension UTMCtl {
|
||||
@objc class EventErrorHandler: NSObject, SBApplicationDelegate {
|
||||
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
|
||||
/// - Parameters:
|
||||
@@ -121,6 +148,7 @@ extension UTMCtl {
|
||||
/// - error: Error
|
||||
/// - Returns: nil
|
||||
func eventDidFail(_ event: UnsafePointer<AppleEvent>, withError error: Error) -> Any? {
|
||||
hasFailed = true
|
||||
let error = error as NSError
|
||||
FileHandle.standardError.write("Error from event: \(error.localizedDescription)")
|
||||
if let user = error.userInfo["ErrorString"] as? String {
|
||||
@@ -140,11 +168,13 @@ extension UTMCtl {
|
||||
case virtualMachineNotFound
|
||||
case invalidIdentifier(String)
|
||||
case deviceNotFound
|
||||
case snapshotNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .applicationNotFound: return "Application 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 .deviceNotFound: return "Device not found."
|
||||
}
|
||||
@@ -741,92 +771,198 @@ 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]
|
||||
abstract: "List and manage the saved states of a virtual machine.",
|
||||
subcommands: [SnapshotList.self, SnapshotCreate.self, SnapshotRestore.self, SnapshotOverwrite.self, SnapshotRename.self, SnapshotDelete.self, SnapshotScan.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)
|
||||
}
|
||||
/// Addresses one snapshot of a virtual machine
|
||||
struct SnapshotIdentifier: ParsableArguments {
|
||||
@Option(name: .shortAndLong, help: "Either the name or the identifier of the snapshot.")
|
||||
var snapshot: String
|
||||
}
|
||||
|
||||
struct SnapshotList: UTMAPICommand {
|
||||
static var configuration = CommandConfiguration(
|
||||
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 identifer: VMIdentifier
|
||||
|
||||
@Flag(help: "Output the list as a JSON array.")
|
||||
@Flag(help: "Output the list as JSON.")
|
||||
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 {
|
||||
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 {
|
||||
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) ?? "[]")
|
||||
} else {
|
||||
for name in names {
|
||||
print(name)
|
||||
let formatter = DateFormatter()
|
||||
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 {
|
||||
static var configuration = CommandConfiguration(
|
||||
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 identifer: VMIdentifier
|
||||
|
||||
@Option(help: "Name of the snapshot to restore.")
|
||||
var name: String
|
||||
@OptionGroup var snapshotIdentifer: SnapshotIdentifier
|
||||
|
||||
func run(with application: UTMScriptingApplication) throws {
|
||||
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 {
|
||||
static var configuration = CommandConfiguration(
|
||||
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 identifer: VMIdentifier
|
||||
|
||||
@Option(help: "Name of the snapshot to delete.")
|
||||
var name: String
|
||||
@OptionGroup var snapshotIdentifer: SnapshotIdentifier
|
||||
|
||||
func run(with application: UTMScriptingApplication) throws {
|
||||
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!()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user