Files
UTM/Intents/UTMActionIntent.swift
T
osy 3b0da37c46 apple: run without saving changes on macOS 27
"Run without saving changes" has only been available for QEMU, where it
maps to `-snapshot`. Apple Virtualization has no such option, but
DiskImageKit on macOS 27 can stack a copy-on-write layer on top of an
image and Virtualization accepts the stacked image as a storage
attachment.

When started with the disposable option, the Apple backend now creates a
temporary directory in the sandbox and gives every writable drive an
ephemeral ASIF overlay there. The base image is opened read-only and is
never touched. The EFI variable store and the macOS auxiliary storage are
cloned into the same directory so firmware settings are not persisted
either. The directory is deleted when the guest stops or when start
fails, any leftover from a previous start is discarded before the next
start, and directories orphaned by a crash are swept at app launch.
Removable drives attached at start or hot-plugged during the run are
covered as well.

A disposable run boots fresh and leaves any saved suspend state and the
modification date alone, and reports the save/restore path as
unsupported so closing the window offers to kill the VM instead of
suspending it, matching the QEMU behaviour. The stop paths that delete a
saved suspend state skip that while running disposably.

The context menu, the Start intent and the menu bar extra now check the
backend capability instead of assuming QEMU, and the Apple display window
shows the same "Disposable Mode" subtitle as QEMU.

Assisted-by: Claude:claude-fable-5-1
2026-09-19 10:51:06 -05:00

217 lines
8.0 KiB
Swift

//
// Copyright © 2025 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import AppIntents
#if os(macOS)
import AppKit
#endif
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
struct UTMStatusActionIntent: AppIntent, UTMIntent {
static let title: LocalizedStringResource = "Get Virtual Machine Status"
static let description = IntentDescription("Get the status of a virtual machine.")
static var parameterSummary: some ParameterSummary {
Summary("Get \(\.$vmEntity) status")
}
@Dependency
var data: UTMData
@Parameter(title: "Virtual Machine", requestValueDialog: "Select a virtual machine")
var vmEntity: UTMVirtualMachineEntity
@MainActor
func perform(with vm: any UTMVirtualMachine, boxed: VMData) async throws -> some ReturnsValue<UTMVirtualMachineState> {
return .result(value: vm.state)
}
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
struct UTMStartActionIntent: AppIntent, UTMIntent {
static let title: LocalizedStringResource = "Start Virtual Machine"
static let description = IntentDescription("Start a virtual machine.")
static var parameterSummary: some ParameterSummary {
Summary("Start \(\.$vmEntity)") {
\.$isRecovery
\.$isDisposible
}
}
@Dependency
var data: UTMData
@Parameter(title: "Virtual Machine", requestValueDialog: "Select a virtual machine")
var vmEntity: UTMVirtualMachineEntity
@Parameter(title: "Recovery", description: "Boot into recovery mode. Only supported on Apple Virtualization backend.", default: false)
var isRecovery: Bool
@Parameter(title: "Disposible", description: "Do not save any changes to disk.", default: false)
var isDisposible: Bool
@MainActor
func perform(with vm: any UTMVirtualMachine, boxed: VMData) async throws -> some IntentResult {
var options = UTMVirtualMachineStartOptions()
if isRecovery {
#if os(macOS)
guard vm is UTMAppleVirtualMachine else {
throw UTMIntentError.unsupportedBackend
}
options.insert(.bootRecovery)
#else
throw UTMIntentError.unsupportedBackend
#endif
}
if isDisposible {
guard type(of: vm).capabilities.supportsDisposibleMode else {
throw UTMIntentError.unsupportedBackend
}
options.insert(.bootDisposibleMode)
}
#if os(macOS)
// Ensure the app comes to the foreground before presenting VM UI
NSApp.activate(ignoringOtherApps: true)
#endif
data.run(vm: boxed, options: options)
// For platforms that support foreground continuation, request it (no-op on older SDKs).
if !vm.isHeadless {
if #available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *), systemContext.currentMode.canContinueInForeground {
try await continueInForeground(alwaysConfirm: false)
}
}
return .result()
}
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
struct UTMStopActionIntent: AppIntent, UTMIntent {
static let title: LocalizedStringResource = "Stop Virtual Machine"
static let description = IntentDescription("Stop a virtual machine.")
static var parameterSummary: some ParameterSummary {
Summary("Stop \(\.$vmEntity) by \(\.$method)")
}
@Dependency
var data: UTMData
@Parameter(title: "Virtual Machine", requestValueDialog: "Select a virtual machine")
var vmEntity: UTMVirtualMachineEntity
@Parameter(title: "Stop Method", description: "Intensity of the stop action.", default: .force)
var method: UTMVirtualMachineStopMethod
@MainActor
func perform(with vm: any UTMVirtualMachine, boxed: VMData) async throws -> some IntentResult {
try await vm.stop(usingMethod: method)
return .result()
}
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
extension UTMVirtualMachineStopMethod: AppEnum {
static let typeDisplayRepresentation: TypeDisplayRepresentation =
TypeDisplayRepresentation(
name: "Stop Method"
)
static let caseDisplayRepresentations: [UTMVirtualMachineStopMethod: DisplayRepresentation] = [
.request: DisplayRepresentation(title: "Request", subtitle: "Sends power down request to the guest. This simulates pressing the power button on a PC."),
.force: DisplayRepresentation(title: "Force", subtitle: "Tells the VM process to shut down with risk of data corruption. This simulates holding down the power button on a PC."),
.kill: DisplayRepresentation(title: "Killing", subtitle: "Force kill the VM process with high risk of data corruption."),
]
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
struct UTMPauseActionIntent: AppIntent, UTMIntent {
static let title: LocalizedStringResource = "Pause Virtual Machine"
static let description = IntentDescription("Pause a virtual machine.")
static var parameterSummary: some ParameterSummary {
Summary("Pause \(\.$vmEntity)") {
\.$isSaveState
}
}
@Dependency
var data: UTMData
@Parameter(title: "Virtual Machine", requestValueDialog: "Select a virtual machine")
var vmEntity: UTMVirtualMachineEntity
@Parameter(title: "Save State", description: "Create a snapshot of the virtual machine state.", default: false)
var isSaveState: Bool
@MainActor
func perform(with vm: any UTMVirtualMachine, boxed: VMData) async throws -> some IntentResult {
try await vm.pause()
if isSaveState {
try await vm.save()
}
return .result()
}
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
struct UTMResumeActionIntent: AppIntent, UTMIntent {
static let title: LocalizedStringResource = "Resume Virtual Machine"
static let description = IntentDescription("Resume a virtual machine.")
static var parameterSummary: some ParameterSummary {
Summary("Resume \(\.$vmEntity)")
}
@Dependency
var data: UTMData
@Parameter(title: "Virtual Machine", requestValueDialog: "Select a virtual machine")
var vmEntity: UTMVirtualMachineEntity
@MainActor
func perform(with vm: any UTMVirtualMachine, boxed: VMData) async throws -> some IntentResult {
try await vm.resume()
if !vm.isHeadless {
if #available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *), systemContext.currentMode.canContinueInForeground {
try await continueInForeground(alwaysConfirm: false)
}
}
return .result()
}
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
struct UTMRestartActionIntent: AppIntent, UTMIntent {
static let title: LocalizedStringResource = "Restart Virtual Machine"
static let description = IntentDescription("Restart a virtual machine.")
static var parameterSummary: some ParameterSummary {
Summary("Restart \(\.$vmEntity)")
}
@Dependency
var data: UTMData
@Parameter(title: "Virtual Machine", requestValueDialog: "Select a virtual machine")
var vmEntity: UTMVirtualMachineEntity
@MainActor
func perform(with vm: any UTMVirtualMachine, boxed: VMData) async throws -> some IntentResult {
try await vm.restart()
if !vm.isHeadless {
if #available(iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26, *), systemContext.currentMode.canContinueInForeground {
try await continueInForeground(alwaysConfirm: false)
}
}
return .result()
}
}