Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions Scripting/UTM.sdef
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,34 @@
</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.">
<direct-parameter description="Virtual machine to snapshot." type="virtual machine"/>
<parameter name="named" code="SnNm" description="The name of the snapshot." type="text">
<cocoa key="snapshotName"/>
</parameter>
</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>

<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>

<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>

<command name="delete" code="coredelo" description="Delete a virtual machine. All data will be deleted, there is no confirmation!">
<cocoa class="UTMScriptingDeleteCommand"/>
<access-group identifier="*"/>
Expand Down Expand Up @@ -148,6 +176,22 @@
<responds-to command="stop">
<cocoa method="stop:"/>
</responds-to>

<responds-to command="create snapshot">
<cocoa method="createSnapshot:"/>
</responds-to>

<responds-to command="list snapshots">
<cocoa method="listSnapshots:"/>
</responds-to>

<responds-to command="restore snapshot">
<cocoa method="restoreSnapshot:"/>
</responds-to>

<responds-to command="delete snapshot">
<cocoa method="deleteSnapshot:"/>
</responds-to>

<responds-to command="delete">
<cocoa method="delete:"/>
Expand Down
4 changes: 4 additions & 0 deletions Scripting/UTMScripting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions Scripting/UTMScriptingVirtualMachineImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions Services/UTMAppleVirtualMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
40 changes: 35 additions & 5 deletions Services/UTMQemuImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
Expand All @@ -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 {
Expand Down
48 changes: 43 additions & 5 deletions Services/UTMQemuVirtualMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
}
Expand Down
Loading
Loading