vm: rewrite UTMVirtualMachine in Swift

This commit is contained in:
osy 2023-06-28 19:45:05 -05:00
parent d498239b38
commit 37991d246f
49 changed files with 1578 additions and 858 deletions

View File

@ -44,9 +44,6 @@ struct UTMAppleConfigurationBoot: Codable {
/// IPSW for installing macOS. Not saved.
var macRecoveryIpswURL: URL?
/// Next startup should be in recovery. Not saved.
var startUpFromMacOSRecovery: Bool = false
private enum CodingKeys: String, CodingKey {
case operatingSystem = "OperatingSystem"
case linuxKernelPath = "LinuxKernelPath"

View File

@ -16,6 +16,8 @@
import Foundation
private let kUTMBundleConfigFilename = "config.plist"
protocol UTMConfiguration: Codable, ObservableObject {
associatedtype Drive: UTMConfigurationDrive
static var oldestVersion: Int { get }
@ -91,6 +93,12 @@ extension UTMConfiguration {
static var dataDirectoryName: String { "Data" }
static func load(from packageURL: URL) throws -> any UTMConfiguration {
let scopedAccess = packageURL.startAccessingSecurityScopedResource()
defer {
if scopedAccess {
packageURL.stopAccessingSecurityScopedResource()
}
}
let dataURL = packageURL.appendingPathComponent(Self.dataDirectoryName)
let configURL = packageURL.appendingPathComponent(kUTMBundleConfigFilename)
let configData = try Data(contentsOf: configURL)
@ -112,7 +120,7 @@ extension UTMConfiguration {
#endif
// is it a legacy QEMU config?
let dict = try NSDictionary(contentsOf: configURL, error: ()) as! [AnyHashable : Any]
let name = UTMVirtualMachine.virtualMachineName(packageURL)
let name = UTMQemuVirtualMachine.virtualMachineName(for: packageURL)
let legacy = UTMLegacyQemuConfiguration(dictionary: dict, name: name, path: packageURL)
return UTMQemuConfiguration(migrating: legacy)
} else if stub.backend == .qemu {

View File

@ -60,6 +60,9 @@ struct UTMQemuConfigurationQEMU: Codable {
/// If true, changes to the VM will not be committed to disk. Not saved.
var isDisposable: Bool = false
/// Set to true to request guest tools install. Not saved.
var isGuestToolsInstallRequested: Bool = false
enum CodingKeys: String, CodingKey {
case hasDebugLog = "DebugLog"
case hasUefiBoot = "UEFIBoot"

View File

@ -19,42 +19,84 @@ import Virtualization
@available(iOS, unavailable, message: "Apple Virtualization not available on iOS")
@available(macOS 11, *)
@objc class UTMAppleVirtualMachine: UTMVirtualMachine {
private let quitTimeoutSeconds = DispatchTimeInterval.seconds(30)
var appleConfig: UTMAppleConfiguration {
config.appleConfig!
}
@MainActor override var detailsTitleLabel: String {
appleConfig.information.name
}
@MainActor override var detailsSubtitleLabel: String {
detailsSystemTargetLabel
}
@MainActor override var detailsNotes: String? {
appleConfig.information.notes
}
@MainActor override var detailsSystemTargetLabel: String {
appleConfig.system.boot.operatingSystem.rawValue
}
@MainActor override var detailsSystemArchitectureLabel: String {
appleConfig.system.architecture
}
@MainActor override var detailsSystemMemoryLabel: String {
let bytesInMib = Int64(1048576)
return ByteCountFormatter.string(fromByteCount: Int64(appleConfig.system.memorySize) * bytesInMib, countStyle: .binary)
}
override var hasSaveState: Bool {
final class UTMAppleVirtualMachine: UTMVirtualMachine {
struct Capabilities: UTMVirtualMachineCapabilities {
var supportsProcessKill: Bool {
false
}
var supportsSnapshots: Bool {
false
}
var supportsScreenshots: Bool {
true
}
var supportsDisposibleMode: Bool {
false
}
var supportsRecoveryMode: Bool {
true
}
}
static let capabilities = Capabilities()
private(set) var pathUrl: URL {
didSet {
if isScopedAccess {
oldValue.stopAccessingSecurityScopedResource()
}
isScopedAccess = pathUrl.startAccessingSecurityScopedResource()
}
}
private(set) var isShortcut: Bool = false
let isRunningAsDisposible: Bool = false
weak var delegate: (any UTMVirtualMachineDelegate)?
var onConfigurationChange: (() -> Void)?
var onStateChange: (() -> Void)?
private(set) var config: UTMAppleConfiguration {
willSet {
onConfigurationChange?()
}
}
private(set) var registryEntry: UTMRegistryEntry {
willSet {
onConfigurationChange?()
}
}
private(set) var state: UTMVirtualMachineState = .stopped {
willSet {
onStateChange?()
}
didSet {
Task { @MainActor in
delegate?.virtualMachine(self, didTransitionToState: state)
}
}
}
private(set) var screenshot: PlatformImage? {
willSet {
onStateChange?()
}
}
private var isScopedAccess: Bool = false
private weak var screenshotTimer: Timer?
private let vmQueue = DispatchQueue(label: "VZVirtualMachineQueue", qos: .userInteractive)
/// This variable MUST be synchronized by `vmQueue`
@ -70,20 +112,46 @@ import Virtualization
private var activeResourceUrls: [URL] = []
@MainActor override func reloadConfiguration() throws {
let newConfig = try UTMAppleConfiguration.load(from: path) as! UTMAppleConfiguration
let oldConfig = appleConfig
config = UTMConfigurationWrapper(wrapping: newConfig)
@MainActor required init(packageUrl: URL, configuration: UTMAppleConfiguration? = nil, isShortcut: Bool = false) throws {
self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
// load configuration
let config: UTMAppleConfiguration
if configuration == nil {
guard let appleConfig = try UTMAppleConfiguration.load(from: packageUrl) as? UTMAppleConfiguration else {
throw UTMConfigurationError.invalidBackend
}
config = appleConfig
} else {
config = configuration!
}
self.config = config
self.pathUrl = packageUrl
self.isShortcut = isShortcut
self.registryEntry = UTMRegistryEntry.empty
self.registryEntry = loadRegistry()
self.screenshot = loadScreenshot()
updateConfigFromRegistry()
}
override func accessShortcut() async throws {
// not needed for Apple VMs
deinit {
if isScopedAccess {
pathUrl.stopAccessingSecurityScopedResource()
}
}
private func _vmStart() async throws {
@MainActor func reload(from packageUrl: URL?) throws {
let packageUrl = packageUrl ?? pathUrl
guard let newConfig = try UTMAppleConfiguration.load(from: packageUrl) as? UTMAppleConfiguration else {
throw UTMConfigurationError.invalidBackend
}
config = newConfig
pathUrl = packageUrl
updateConfigFromRegistry()
}
private func _start(options: UTMVirtualMachineStartOptions) async throws {
try await createAppleVM()
let boot = await appleConfig.system.boot
let boot = await config.system.boot
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) -> Void in
vmQueue.async {
guard let apple = self.apple else {
@ -92,9 +160,9 @@ import Virtualization
}
#if os(macOS) && arch(arm64)
if #available(macOS 13, *), boot.operatingSystem == .macOS {
let options = VZMacOSVirtualMachineStartOptions()
options.startUpFromMacOSRecovery = boot.startUpFromMacOSRecovery
apple.start(options: options) { result in
let vzoptions = VZMacOSVirtualMachineStartOptions()
vzoptions.startUpFromMacOSRecovery = options.contains(.bootRecovery)
apple.start(options: vzoptions) { result in
if let result = result {
continuation.resume(with: .failure(result))
} else {
@ -111,17 +179,17 @@ import Virtualization
}
}
override func vmStart() async throws {
guard state == .vmStopped else {
func start(options: UTMVirtualMachineStartOptions = []) async throws {
guard state == .stopped else {
return
}
changeState(.vmStarting)
state = .starting
do {
try await beginAccessingResources()
try await _vmStart()
try await _start(options: options)
if #available(macOS 12, *) {
Task { @MainActor in
sharedDirectoriesChanged = appleConfig.sharedDirectoriesPublisher.sink { [weak self] newShares in
sharedDirectoriesChanged = config.sharedDirectoriesPublisher.sink { [weak self] newShares in
guard let self = self else {
return
}
@ -131,16 +199,19 @@ import Virtualization
}
}
}
changeState(.vmStarted)
state = .started
if screenshotTimer == nil {
screenshotTimer = startScreenshotTimer()
}
} catch {
await stopAccesingResources()
changeState(.vmStopped)
state = .stopped
throw error
}
}
private func _vmStop(force: Bool) async throws {
if force, #available(macOS 12, *) {
private func _stop(usingMethod method: UTMVirtualMachineStopMethod) async throws {
if method != .request, #available(macOS 12, *) {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
vmQueue.async {
guard let apple = self.apple else {
@ -175,25 +246,25 @@ import Virtualization
}
}
override func vmStop(force: Bool) async throws {
func stop(usingMethod method: UTMVirtualMachineStopMethod = .request) async throws {
if let installProgress = installProgress {
installProgress.cancel()
return
}
guard state == .vmStarted || state == .vmPaused else {
guard state == .started || state == .paused else {
return
}
changeState(.vmStopping)
state = .stopping
do {
try await _vmStop(force: force)
changeState(.vmStopped)
try await _stop(usingMethod: method)
state = .stopped
} catch {
changeState(.vmStopped)
state = .stopped
throw error
}
}
private func _vmReset() async throws {
private func _restart() async throws {
guard #available(macOS 12, *) else {
return
}
@ -216,31 +287,31 @@ import Virtualization
}
}
override func vmReset() async throws {
guard state == .vmStarted || state == .vmPaused else {
func restart() async throws {
guard state == .started || state == .paused else {
return
}
changeState(.vmStopping)
state = .stopping
do {
try await _vmReset()
changeState(.vmStarted)
try await _restart()
state = .started
} catch {
changeState(.vmStopped)
state = .stopped
throw error
}
}
private func _vmPause() async throws {
private func _pause() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
vmQueue.async {
guard let apple = self.apple else {
continuation.resume(throwing: UTMAppleVirtualMachineError.operationNotAvailable)
return
}
DispatchQueue.main.sync {
self.updateScreenshot()
Task { @MainActor in
await self.takeScreenshot()
try? self.saveScreenshot()
}
self.saveScreenshot()
apple.pause { result in
continuation.resume(with: result)
}
@ -248,26 +319,33 @@ import Virtualization
}
}
override func vmPause(save: Bool) async throws {
changeState(.vmPausing)
func pause() async throws {
guard state == .started else {
return
}
state = .pausing
do {
try await _vmPause()
changeState(.vmPaused)
try await _pause()
state = .paused
} catch {
changeState(.vmStopped)
state = .stopped
throw error
}
}
override func vmSaveState() async throws {
func saveSnapshot(name: String? = nil) async throws {
// FIXME: implement this
}
override func vmDeleteState() async throws {
func deleteSnapshot(name: String? = nil) async throws {
// FIXME: implement this
}
private func _vmResume() async throws {
func restoreSnapshot(name: String? = nil) async throws {
// FIXME: implement this
}
private func _resume() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
vmQueue.async {
guard let apple = self.apple else {
@ -281,52 +359,37 @@ import Virtualization
}
}
override func vmResume() async throws {
guard state == .vmPaused else {
func resume() async throws {
guard state == .paused else {
return
}
changeState(.vmResuming)
state = .resuming
do {
try await _vmResume()
changeState(.vmStarted)
try await _resume()
state = .started
} catch {
changeState(.vmStopped)
state = .stopped
throw error
}
}
override func vmGuestPowerDown() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
vmQueue.async {
guard let apple = self.apple else {
continuation.resume() // already stopped
return
}
do {
try apple.requestStop()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
override func updateScreenshot() {
@discardableResult @MainActor
func takeScreenshot() async -> Bool {
screenshot = screenshotDelegate?.screenshot
return true
}
@MainActor private func createAppleVM() throws {
for i in appleConfig.serials.indices {
for i in config.serials.indices {
let (fd, sfd, name) = try createPty()
let terminalTtyHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
let slaveTtyHandle = FileHandle(fileDescriptor: sfd, closeOnDealloc: false)
appleConfig.serials[i].fileHandleForReading = terminalTtyHandle
appleConfig.serials[i].fileHandleForWriting = terminalTtyHandle
config.serials[i].fileHandleForReading = terminalTtyHandle
config.serials[i].fileHandleForWriting = terminalTtyHandle
let serialPort = UTMSerialPort(portNamed: name, readFileHandle: slaveTtyHandle, writeFileHandle: slaveTtyHandle, terminalFileHandle: terminalTtyHandle)
appleConfig.serials[i].interface = serialPort
config.serials[i].interface = serialPort
}
let vzConfig = try appleConfig.appleVZConfiguration()
let vzConfig = try config.appleVZConfiguration()
vmQueue.async { [self] in
apple = VZVirtualMachine(configuration: vzConfig, queue: vmQueue)
apple!.delegate = self
@ -349,10 +412,10 @@ import Virtualization
@available(macOS 12, *)
func installVM(with ipswUrl: URL) async throws {
guard state == .vmStopped else {
guard state == .stopped else {
return
}
changeState(.vmStarting)
state = .starting
do {
_ = ipswUrl.startAccessingSecurityScopedResource()
defer {
@ -368,7 +431,9 @@ import Virtualization
}
let installer = VZMacOSInstaller(virtualMachine: apple, restoringFromImageAt: ipswUrl)
self.progressObserver = installer.progress.observe(\.fractionCompleted, options: [.initial, .new]) { progress, change in
self.delegate?.virtualMachine?(self, didUpdateInstallationProgress: progress.fractionCompleted)
Task { @MainActor in
self.delegate?.virtualMachine(self, didUpdateInstallationProgress: progress.fractionCompleted)
}
}
self.installProgress = installer.progress
installer.install { result in
@ -376,30 +441,21 @@ import Virtualization
}
}
}
changeState(.vmStarted)
state = .started
progressObserver = nil
installProgress = nil
delegate?.virtualMachine?(self, didCompleteInstallation: true)
await MainActor.run {
delegate?.virtualMachine(self, didCompleteInstallation: true)
}
#else
throw UTMAppleVirtualMachineError.operatingSystemInstallNotSupported
#endif
} catch {
delegate?.virtualMachine?(self, didCompleteInstallation: false)
changeState(.vmStopped)
throw error
}
}
@available(macOS 12, *)
func requestInstallVM(with ipswUrl: URL) {
Task {
do {
try await installVM(with: ipswUrl)
} catch {
await MainActor.run {
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
delegate?.virtualMachine(self, didCompleteInstallation: false)
}
state = .stopped
throw error
}
}
@ -439,24 +495,24 @@ import Virtualization
}
@MainActor private func beginAccessingResources() throws {
for i in appleConfig.drives.indices {
let drive = appleConfig.drives[i]
for i in config.drives.indices {
let drive = config.drives[i]
if let url = drive.imageURL, drive.isExternal {
if url.startAccessingSecurityScopedResource() {
activeResourceUrls.append(url)
} else {
appleConfig.drives[i].imageURL = nil
config.drives[i].imageURL = nil
throw UTMAppleVirtualMachineError.cannotAccessResource(url)
}
}
}
for i in appleConfig.sharedDirectories.indices {
let share = appleConfig.sharedDirectories[i]
for i in config.sharedDirectories.indices {
let share = config.sharedDirectories[i]
if let url = share.directoryURL {
if url.startAccessingSecurityScopedResource() {
activeResourceUrls.append(url)
} else {
appleConfig.sharedDirectories[i].directoryURL = nil
config.sharedDirectories[i].directoryURL = nil
throw UTMAppleVirtualMachineError.cannotAccessResource(url)
}
}
@ -480,28 +536,88 @@ extension UTMAppleVirtualMachine: VZVirtualMachineDelegate {
sharedDirectoriesChanged = nil
Task { @MainActor in
stopAccesingResources()
for i in appleConfig.serials.indices {
if let serialPort = appleConfig.serials[i].interface {
for i in config.serials.indices {
if let serialPort = config.serials[i].interface {
serialPort.close()
appleConfig.serials[i].interface = nil
appleConfig.serials[i].fileHandleForReading = nil
appleConfig.serials[i].fileHandleForWriting = nil
config.serials[i].interface = nil
config.serials[i].fileHandleForReading = nil
config.serials[i].fileHandleForWriting = nil
}
}
}
changeState(.vmStopped)
state = .stopped
}
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
guestDidStop(virtualMachine)
DispatchQueue.main.async {
Task { @MainActor in
self.delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
// fake methods to adhere to NSObjectProtocol
func isEqual(_ object: Any?) -> Bool {
self === object as? UTMAppleVirtualMachine
}
var hash: Int {
0
}
var superclass: AnyClass? {
nil
}
func `self`() -> Self {
self
}
func perform(_ aSelector: Selector!) -> Unmanaged<AnyObject>! {
nil
}
func perform(_ aSelector: Selector!, with object: Any!) -> Unmanaged<AnyObject>! {
nil
}
func perform(_ aSelector: Selector!, with object1: Any!, with object2: Any!) -> Unmanaged<AnyObject>! {
nil
}
func isProxy() -> Bool {
false
}
func isKind(of aClass: AnyClass) -> Bool {
false
}
func isMember(of aClass: AnyClass) -> Bool {
false
}
func conforms(to aProtocol: Protocol) -> Bool {
aProtocol is VZVirtualMachineDelegate
}
func responds(to aSelector: Selector!) -> Bool {
if aSelector == #selector(VZVirtualMachineDelegate.guestDidStop(_:)) {
return true
}
if aSelector == #selector(VZVirtualMachineDelegate.virtualMachine(_:didStopWithError:)) {
return true
}
return false
}
var description: String {
""
}
}
protocol UTMScreenshotProvider: AnyObject {
var screenshot: CSScreenshot? { get }
var screenshot: PlatformImage? { get }
}
enum UTMAppleVirtualMachineError: Error {
@ -525,11 +641,11 @@ extension UTMAppleVirtualMachineError: LocalizedError {
// MARK: - Registry access
extension UTMAppleVirtualMachine {
@MainActor override func updateRegistryFromConfig() async throws {
@MainActor func updateRegistryFromConfig() async throws {
// save a copy to not collide with updateConfigFromRegistry()
let configShares = appleConfig.sharedDirectories
let configDrives = appleConfig.drives
try await super.updateRegistryFromConfig()
let configShares = config.sharedDirectories
let configDrives = config.drives
try await updateRegistryBasics()
registryEntry.sharedDirectories.removeAll(keepingCapacity: true)
for sharedDirectory in configShares {
if let url = sharedDirectory.directoryURL {
@ -552,24 +668,51 @@ extension UTMAppleVirtualMachine {
configDrives.contains(where: { $0.id == element.key && $0.isExternal })
})
// save IPSW reference
if let url = appleConfig.system.boot.macRecoveryIpswURL {
if let url = config.system.boot.macRecoveryIpswURL {
_ = url.startAccessingSecurityScopedResource()
registryEntry.macRecoveryIpsw = try UTMRegistryEntry.File(url: url, isReadOnly: true)
url.stopAccessingSecurityScopedResource()
}
}
@MainActor override func updateConfigFromRegistry() {
super.updateConfigFromRegistry()
appleConfig.sharedDirectories = registryEntry.sharedDirectories.map({ UTMAppleConfigurationSharedDirectory(directoryURL: $0.url, isReadOnly: $0.isReadOnly )})
for i in appleConfig.drives.indices {
let id = appleConfig.drives[i].id
if appleConfig.drives[i].isExternal {
appleConfig.drives[i].imageURL = registryEntry.externalDrives[id]?.url
@MainActor func updateConfigFromRegistry() {
config.sharedDirectories = registryEntry.sharedDirectories.map({ UTMAppleConfigurationSharedDirectory(directoryURL: $0.url, isReadOnly: $0.isReadOnly )})
for i in config.drives.indices {
let id = config.drives[i].id
if config.drives[i].isExternal {
config.drives[i].imageURL = registryEntry.externalDrives[id]?.url
}
}
if let file = registryEntry.macRecoveryIpsw {
appleConfig.system.boot.macRecoveryIpswURL = file.url
config.system.boot.macRecoveryIpswURL = file.url
}
}
@MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyingEntry entry: UTMRegistryEntry? = nil) {
config.information.uuid = uuid
if let name = name {
config.information.name = name
}
registryEntry = UTMRegistry.shared.entry(for: self)
if let entry = entry {
registryEntry.update(copying: entry)
}
}
}
// MARK: - Non-asynchronous version (to be removed)
extension UTMAppleVirtualMachine {
@available(macOS 12, *)
func requestInstallVM(with url: URL) {
Task {
do {
try await installVM(with: url)
} catch {
await MainActor.run {
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
}
}
}

View File

@ -21,11 +21,85 @@ private var SpiceIoServiceGuestAgentContext = 0
private let kSuspendSnapshotName = "suspend"
/// QEMU backend virtual machine
@objc class UTMQemuVirtualMachine: UTMVirtualMachine {
/// Set to true to request guest tools install.
///
/// This property is observable and must only be accessed on the main thread.
@Published var isGuestToolsInstallRequested: Bool = false
final class UTMQemuVirtualMachine: UTMVirtualMachine {
struct Capabilities: UTMVirtualMachineCapabilities {
var supportsProcessKill: Bool {
true
}
var supportsSnapshots: Bool {
true
}
var supportsScreenshots: Bool {
true
}
var supportsDisposibleMode: Bool {
true
}
var supportsRecoveryMode: Bool {
false
}
}
static let capabilities = Capabilities()
private(set) var pathUrl: URL {
didSet {
if isScopedAccess {
oldValue.stopAccessingSecurityScopedResource()
}
isScopedAccess = pathUrl.startAccessingSecurityScopedResource()
}
}
private(set) var isShortcut: Bool = false
private(set) var isRunningAsDisposible: Bool = false
weak var delegate: (any UTMVirtualMachineDelegate)?
var onConfigurationChange: (() -> Void)?
var onStateChange: (() -> Void)?
private(set) var config: UTMQemuConfiguration {
willSet {
onConfigurationChange?()
}
}
private(set) var registryEntry: UTMRegistryEntry {
willSet {
onConfigurationChange?()
}
}
private(set) var state: UTMVirtualMachineState = .stopped {
willSet {
onStateChange?()
}
didSet {
Task { @MainActor in
delegate?.virtualMachine(self, didTransitionToState: state)
}
}
}
private(set) var screenshot: PlatformImage? {
willSet {
onStateChange?()
}
}
private var logging: QEMULogging
private var isScopedAccess: Bool = false
private weak var screenshotTimer: Timer?
/// Handle SPICE IO related events
weak var ioServiceDelegate: UTMSpiceIODelegate? {
@ -67,11 +141,53 @@ private let kSuspendSnapshotName = "suspend"
}
private var startTask: Task<Void, any Error>?
@MainActor required init(packageUrl: URL, configuration: UTMQemuConfiguration? = nil, isShortcut: Bool = false) throws {
self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
// load configuration
let config: UTMQemuConfiguration
if configuration == nil {
guard let qemuConfig = try UTMQemuConfiguration.load(from: packageUrl) as? UTMQemuConfiguration else {
throw UTMConfigurationError.invalidBackend
}
config = qemuConfig
} else {
config = configuration!
}
#if os(macOS)
self.logging = QEMULogging()
#else
self.logging = QEMULogging.sharedInstance()
#endif
self.config = config
self.pathUrl = packageUrl
self.isShortcut = isShortcut
self.registryEntry = UTMRegistryEntry.empty
self.registryEntry = loadRegistry()
self.screenshot = loadScreenshot()
updateConfigFromRegistry()
}
deinit {
if isScopedAccess {
pathUrl.stopAccessingSecurityScopedResource()
}
}
@MainActor func reload(from packageUrl: URL?) throws {
let packageUrl = packageUrl ?? pathUrl
guard let qemuConfig = try UTMQemuConfiguration.load(from: packageUrl) as? UTMQemuConfiguration else {
throw UTMConfigurationError.invalidBackend
}
config = qemuConfig
pathUrl = packageUrl
updateConfigFromRegistry()
}
}
// MARK: - Shortcut access
extension UTMQemuVirtualMachine {
override func accessShortcut() async throws {
func accessShortcut() async throws {
guard isShortcut else {
return
}
@ -81,7 +197,7 @@ extension UTMQemuVirtualMachine {
let existing = bookmark != nil
if !existing {
// create temporary bookmark
bookmark = try path.bookmarkData()
bookmark = try pathUrl.bookmarkData()
} else {
let bookmarkPath = await registryEntry.package.path
// in case old path is still accessed
@ -109,34 +225,35 @@ extension UTMQemuVirtualMachine {
}
@MainActor private func qemuEnsureEfiVarsAvailable() async throws {
guard let efiVarsURL = qemuConfig.qemu.efiVarsURL else {
guard let efiVarsURL = config.qemu.efiVarsURL else {
return
}
guard qemuConfig.isLegacy else {
guard config.isLegacy else {
return
}
_ = try await qemuConfig.qemu.saveData(to: efiVarsURL.deletingLastPathComponent(), for: qemuConfig.system)
_ = try await config.qemu.saveData(to: efiVarsURL.deletingLastPathComponent(), for: config.system)
}
private func _vmStart() async throws {
private func _start(options: UTMVirtualMachineStartOptions) async throws {
// check if we can actually start this VM
guard isSupported else {
guard await isSupported else {
throw UTMQemuVirtualMachineError.emulationNotSupported
}
// start logging
if await qemuConfig.qemu.hasDebugLog, let debugLogURL = await qemuConfig.qemu.debugLogURL {
if await config.qemu.hasDebugLog, let debugLogURL = await config.qemu.debugLogURL {
logging.log(toFile: debugLogURL)
}
let isRunningAsDisposible = options.contains(.bootDisposibleMode)
await MainActor.run {
qemuConfig.qemu.isDisposable = isRunningAsSnapshot
config.qemu.isDisposable = isRunningAsDisposible
}
let allArguments = await qemuConfig.allArguments
let allArguments = await config.allArguments
let arguments = allArguments.map({ $0.string })
let resources = allArguments.compactMap({ $0.fileUrls }).flatMap({ $0 })
let remoteBookmarks = await remoteBookmarks
let system = await UTMQemuSystem(arguments: arguments, architecture: qemuConfig.system.architecture.rawValue)
let system = await UTMQemuSystem(arguments: arguments, architecture: config.system.architecture.rawValue)
system.resources = resources
system.remoteBookmarks = remoteBookmarks as NSDictionary
system.rendererBackend = rendererBackend
@ -147,7 +264,18 @@ extension UTMQemuVirtualMachine {
try Task.checkCancellation()
}
let ioService = UTMSpiceIO(configuration: config)
var options = UTMSpiceIOOptions()
if await !config.sound.isEmpty {
options.insert(.hasAudio)
}
if await config.sharing.hasClipboardSharing {
options.insert(.hasClipboardSharing)
}
if await config.sharing.isDirectoryShareReadOnly {
options.insert(.isShareReadOnly)
}
let spiceSocketUrl = await config.spiceSocketURL
let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
try ioService.start()
try Task.checkCancellation()
@ -163,7 +291,7 @@ extension UTMQemuVirtualMachine {
try Task.checkCancellation()
// load saved state if requested
if !isRunningAsSnapshot, await registryEntry.isSuspended {
if !isRunningAsDisposible, await registryEntry.isSuspended {
try await monitor.qemuRestoreSnapshot(kSuspendSnapshotName)
try Task.checkCancellation()
}
@ -178,62 +306,75 @@ extension UTMQemuVirtualMachine {
// delete saved state
if await registryEntry.isSuspended {
try? await _vmDeleteState()
try? await deleteSnapshot()
}
// save ioService and let it set the delegate
self.ioService = ioService
self.isRunningAsDisposible = isRunningAsDisposible
}
override func vmStart() async throws {
guard state == .vmStopped else {
func start(options: UTMVirtualMachineStartOptions = []) async throws {
guard state == .stopped else {
throw UTMQemuVirtualMachineError.invalidVmState
}
changeState(.vmStarting)
state = .starting
do {
startTask = Task {
try await _vmStart()
try await _start(options: options)
}
defer {
startTask = nil
}
try await startTask!.value
changeState(.vmStarted)
state = .started
if screenshotTimer == nil {
screenshotTimer = startScreenshotTimer()
}
} catch {
// delete suspend state on error
await registryEntry.setIsSuspended(false)
changeState(.vmStopped)
state = .stopped
throw error
}
}
override func vmStop(force: Bool) async throws {
if force {
func stop(usingMethod method: UTMVirtualMachineStopMethod) async throws {
if method == .request {
guard let monitor = await monitor else {
throw UTMQemuVirtualMachineError.invalidVmState
}
try await monitor.qemuPowerDown()
return
}
let kill = method == .kill
if kill {
// prevent deadlock force stopping during startup
ioService?.disconnect()
}
guard state != .vmStopped else {
guard state != .stopped else {
return // nothing to do
}
guard force || state == .vmStarted else {
guard kill || state == .started || state == .paused else {
throw UTMQemuVirtualMachineError.invalidVmState
}
if !force {
changeState(.vmStopping)
if !kill {
state = .stopping
}
defer {
changeState(.vmStopped)
state = .stopped
}
if force {
if kill {
await qemuVM.kill()
} else {
try await qemuVM.stop()
}
isRunningAsDisposible = false
}
private func _vmReset() async throws {
private func _restart() async throws {
if await registryEntry.isSuspended {
try? await _vmDeleteState()
try? await deleteSnapshot()
}
guard let monitor = await qemuVM.monitor else {
throw UTMQemuVirtualMachineError.invalidVmState
@ -241,72 +382,77 @@ extension UTMQemuVirtualMachine {
try await monitor.qemuReset()
}
override func vmReset() async throws {
guard state == .vmStarted || state == .vmPaused else {
func restart() async throws {
guard state == .started || state == .paused else {
throw UTMQemuVirtualMachineError.invalidVmState
}
changeState(.vmStopping)
state = .stopping
do {
try await _vmReset()
changeState(.vmStarted)
try await _restart()
state = .started
} catch {
changeState(.vmStopped)
state = .stopped
throw error
}
}
private func _vmPause() async throws {
private func _pause() async throws {
guard let monitor = await monitor else {
throw UTMQemuVirtualMachineError.invalidVmState
}
await updateScreenshot()
await saveScreenshot()
await takeScreenshot()
try saveScreenshot()
try await monitor.qemuStop()
}
override func vmPause(save: Bool) async throws {
guard state == .vmStarted else {
func pause() async throws {
guard state == .started else {
throw UTMQemuVirtualMachineError.invalidVmState
}
changeState(.vmPausing)
state = .pausing
do {
try await _vmPause()
if save {
try? await _vmSaveState()
}
changeState(.vmPaused)
try await _pause()
state = .paused
} catch {
changeState(.vmStopped)
state = .stopped
throw error
}
}
private func _vmSaveState() async throws {
private func _saveSnapshot(name: String) async throws {
guard let monitor = await monitor else {
throw UTMQemuVirtualMachineError.invalidVmState
}
do {
let result = try await monitor.qemuSaveSnapshot(kSuspendSnapshotName)
let result = try await monitor.qemuSaveSnapshot(name)
if result.localizedCaseInsensitiveContains("Error") {
throw UTMQemuVirtualMachineError.qemuError(result)
}
await registryEntry.setIsSuspended(true)
await saveScreenshot()
try saveScreenshot()
} catch {
throw UTMQemuVirtualMachineError.saveSnapshotFailed(error)
}
}
override func vmSaveState() async throws {
guard state == .vmPaused || state == .vmStarted else {
func saveSnapshot(name: String? = nil) async throws {
guard state == .paused || state == .started else {
throw UTMQemuVirtualMachineError.invalidVmState
}
try await _vmSaveState()
let prev = state
state = .saving
do {
try await _saveSnapshot(name: name ?? kSuspendSnapshotName)
state = prev
} catch {
state = .stopped
throw error
}
}
private func _vmDeleteState() async throws {
private func _deleteSnapshot(name: String) async throws {
if let monitor = await monitor { // if QEMU is running
let result = try await monitor.qemuDeleteSnapshot(kSuspendSnapshotName)
let result = try await monitor.qemuDeleteSnapshot(name)
if result.localizedCaseInsensitiveContains("Error") {
throw UTMQemuVirtualMachineError.qemuError(result)
}
@ -314,39 +460,57 @@ extension UTMQemuVirtualMachine {
await registryEntry.setIsSuspended(false)
}
override func vmDeleteState() async throws {
try await _vmDeleteState()
func deleteSnapshot(name: String? = nil) async throws {
try await _deleteSnapshot(name: name ?? kSuspendSnapshotName)
}
private func _vmResume() async throws {
private func _resume() async throws {
guard let monitor = await monitor else {
throw UTMQemuVirtualMachineError.invalidVmState
}
try await monitor.qemuResume()
if await registryEntry.isSuspended {
try? await _vmDeleteState()
try? await deleteSnapshot()
}
}
override func vmResume() async throws {
guard state == .vmPaused else {
func resume() async throws {
guard state == .paused else {
throw UTMQemuVirtualMachineError.invalidVmState
}
changeState(.vmResuming)
state = .resuming
do {
try await _vmResume()
changeState(.vmStarted)
try await _resume()
state = .started
} catch {
changeState(.vmStopped)
state = .stopped
throw error
}
}
override func vmGuestPowerDown() async throws {
private func _restoreSnapshot(name: String) async throws {
guard let monitor = await monitor else {
throw UTMQemuVirtualMachineError.invalidVmState
}
try await monitor.qemuPowerDown()
let result = try await monitor.qemuRestoreSnapshot(name)
if result.localizedCaseInsensitiveContains("Error") {
throw UTMQemuVirtualMachineError.qemuError(result)
}
}
func restoreSnapshot(name: String? = nil) async throws {
guard state == .paused || state == .started else {
throw UTMQemuVirtualMachineError.invalidVmState
}
let prev = state
state = .restoring
do {
try await _restoreSnapshot(name: name ?? kSuspendSnapshotName)
state = prev
} catch {
state = .stopped
throw error
}
}
/// Attempt to cancel the current operation
@ -368,12 +532,14 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
}
func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) {
changeState(.vmStopped)
state = .stopped
}
func qemuVM(_ qemuVM: QEMUVirtualMachine, didError error: Error) {
Task { @MainActor in
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
func qemuVM(_ qemuVM: QEMUVirtualMachine, didCreatePttyDevice path: String, label: String) {
let scanner = Scanner(string: label)
@ -388,11 +554,11 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
}
let index = term
Task { @MainActor in
guard index >= 0 && index < qemuConfig.serials.count else {
guard index >= 0 && index < config.serials.count else {
logger.error("Serial device '\(path)' out of bounds for index \(index)")
return
}
qemuConfig.serials[index].pttyDevice = URL(fileURLWithPath: path)
config.serials[index].pttyDevice = URL(fileURLWithPath: path)
}
}
}
@ -413,52 +579,16 @@ extension UTMQemuVirtualMachine {
// MARK: - Screenshot
extension UTMQemuVirtualMachine {
@MainActor
override func updateScreenshot() {
ioService?.screenshot(completion: { screenshot in
Task { @MainActor in
self.screenshot = screenshot
}
})
}
@MainActor
override func saveScreenshot() {
super.saveScreenshot()
@MainActor @discardableResult
func takeScreenshot() async -> Bool {
let screenshot = await ioService?.screenshot()
self.screenshot = screenshot?.image
return true
}
}
// MARK: - Display details
// MARK: - Architecture supported
extension UTMQemuVirtualMachine {
internal var qemuConfig: UTMQemuConfiguration {
config.qemuConfig!
}
@MainActor override var detailsTitleLabel: String {
qemuConfig.information.name
}
@MainActor override var detailsSubtitleLabel: String {
detailsSystemTargetLabel
}
@MainActor override var detailsNotes: String? {
qemuConfig.information.notes
}
@MainActor override var detailsSystemTargetLabel: String {
qemuConfig.system.target.prettyValue
}
@MainActor override var detailsSystemArchitectureLabel: String {
qemuConfig.system.architecture.prettyValue
}
@MainActor override var detailsSystemMemoryLabel: String {
let bytesInMib = Int64(1048576)
return ByteCountFormatter.string(fromByteCount: Int64(qemuConfig.system.memorySize) * bytesInMib, countStyle: .binary)
}
/// Check if a QEMU target is supported
/// - Parameter systemArchitecture: QEMU architecture
/// - Returns: true if UTM is compiled with the supporting binaries
@ -478,8 +608,8 @@ extension UTMQemuVirtualMachine {
}
/// Check if the current VM target is supported by the host
@objc var isSupported: Bool {
return UTMQemuVirtualMachine.isSupported(systemArchitecture: qemuConfig._system.architecture)
@MainActor var isSupported: Bool {
return UTMQemuVirtualMachine.isSupported(systemArchitecture: config.system.architecture)
}
}
@ -526,7 +656,7 @@ extension UTMQemuVirtualMachine {
guard await system != nil else {
throw UTMQemuVirtualMachineError.invalidVmState
}
for drive in await qemuConfig.drives {
for drive in await config.drives {
if !drive.isExternal {
continue
}
@ -545,7 +675,7 @@ extension UTMQemuVirtualMachine {
}
}
@objc func restoreExternalDrivesAndShares(completion: @escaping (Error?) -> Void) {
func restoreExternalDrivesAndShares(completion: @escaping (Error?) -> Void) {
Task.detached {
do {
try await self.restoreExternalDrives()
@ -581,13 +711,13 @@ extension UTMQemuVirtualMachine {
defer {
url.stopAccessingSecurityScopedResource()
}
let file = try await UTMRegistryEntry.File(url: url, isReadOnly: qemuConfig.sharing.isDirectoryShareReadOnly)
let file = try await UTMRegistryEntry.File(url: url, isReadOnly: config.sharing.isDirectoryShareReadOnly)
await registryEntry.setSingleSharedDirectory(file)
if await qemuConfig.sharing.directoryShareMode == .webdav {
if await config.sharing.directoryShareMode == .webdav {
if let ioService = ioService {
ioService.changeSharedDirectory(url)
}
} else if await qemuConfig.sharing.directoryShareMode == .virtfs {
} else if await config.sharing.directoryShareMode == .virtfs {
let tempBookmark = try url.bookmarkData()
try await changeVirtfsSharedDirectory(with: tempBookmark, isSecurityScoped: false)
}
@ -606,7 +736,7 @@ extension UTMQemuVirtualMachine {
guard let share = await registryEntry.sharedDirectories.first else {
return
}
if await qemuConfig.sharing.directoryShareMode == .virtfs {
if await config.sharing.directoryShareMode == .virtfs {
if let bookmark = share.remoteBookmark {
// a share bookmark was saved while QEMU was running
try await changeVirtfsSharedDirectory(with: bookmark, isSecurityScoped: true)
@ -615,7 +745,7 @@ extension UTMQemuVirtualMachine {
let url = try URL(resolvingPersistentBookmarkData: share.bookmark)
try await changeSharedDirectory(to: url)
}
} else if await qemuConfig.sharing.directoryShareMode == .webdav {
} else if await config.sharing.directoryShareMode == .webdav {
if let ioService = ioService {
ioService.changeSharedDirectory(share.url)
}
@ -625,11 +755,11 @@ extension UTMQemuVirtualMachine {
// MARK: - Registry syncing
extension UTMQemuVirtualMachine {
@MainActor override func updateRegistryFromConfig() async throws {
@MainActor func updateRegistryFromConfig() async throws {
// save a copy to not collide with updateConfigFromRegistry()
let configShare = qemuConfig.sharing.directoryShareUrl
let configDrives = qemuConfig.drives
try await super.updateRegistryFromConfig()
let configShare = config.sharing.directoryShareUrl
let configDrives = config.drives
try await updateRegistryBasics()
for drive in configDrives {
if drive.isExternal, let url = drive.imageURL {
try await changeMedium(drive, to: url)
@ -644,18 +774,28 @@ extension UTMQemuVirtualMachine {
})
}
@MainActor override func updateConfigFromRegistry() {
super.updateConfigFromRegistry()
qemuConfig.sharing.directoryShareUrl = sharedDirectoryURL
for i in qemuConfig.drives.indices {
let id = qemuConfig.drives[i].id
if qemuConfig.drives[i].isExternal {
qemuConfig.drives[i].imageURL = registryEntry.externalDrives[id]?.url
@MainActor func updateConfigFromRegistry() {
config.sharing.directoryShareUrl = sharedDirectoryURL
for i in config.drives.indices {
let id = config.drives[i].id
if config.drives[i].isExternal {
config.drives[i].imageURL = registryEntry.externalDrives[id]?.url
}
}
}
@MainActor @objc var remoteBookmarks: [URL: Data] {
@MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyingEntry entry: UTMRegistryEntry? = nil) {
config.information.uuid = uuid
if let name = name {
config.information.name = name
}
registryEntry = UTMRegistry.shared.entry(for: self)
if let entry = entry {
registryEntry.update(copying: entry)
}
}
@MainActor var remoteBookmarks: [URL: Data] {
var dict = [URL: Data]()
for file in registryEntry.externalDrives.values {
if let bookmark = file.remoteBookmark {

View File

@ -73,8 +73,8 @@ class UTMRegistry: NSObject {
/// Gets an existing registry entry or create a new entry
/// - Parameter vm: UTM virtual machine to locate in the registry
/// - Returns: Either an existing registry entry or a new entry
@objc func entry(for vm: UTMVirtualMachine) -> UTMRegistryEntry {
if let entry = entries[vm.config.uuid.uuidString] {
func entry(for vm: any UTMVirtualMachine) -> UTMRegistryEntry {
if let entry = entries[vm.id.uuidString] {
return entry
}
let newEntry = UTMRegistryEntry(newFrom: vm)

View File

@ -17,6 +17,9 @@
import Foundation
@objc class UTMRegistryEntry: NSObject, Codable, ObservableObject {
/// Empty registry entry used only as a workaround for object initialization
static let empty = UTMRegistryEntry(uuid: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, name: "", path: "")
@Published private var _name: String
@Published private var _package: File
@ -68,9 +71,9 @@ import Foundation
_hasMigratedConfig = false
}
convenience init(newFrom vm: UTMVirtualMachine) {
self.init(uuid: vm.config.uuid, name: vm.config.name, path: vm.path.path)
if let package = try? File(url: vm.path) {
convenience init(newFrom vm: any UTMVirtualMachine) {
self.init(uuid: vm.id, name: vm.name, path: vm.pathUrl.path)
if let package = try? File(url: vm.pathUrl) {
_package = package
}
}
@ -112,6 +115,15 @@ import Foundation
let dict = try PropertyListSerialization.propertyList(from: xml, format: nil)
return dict as! [String: Any]
}
/// Update the UUID
///
/// Should only be called from `UTMRegistry`!
/// - Parameter uuid: UUID to change to
func _updateUuid(_ uuid: UUID) {
self.objectWillChange.send()
self.uuid = uuid
}
}
protocol UTMRegistryEntryDecodable: Decodable {}

View File

@ -23,13 +23,18 @@
@import CocoaSpice;
#endif
@class UTMConfigurationWrapper;
/// Options for initializing UTMSpiceIO
typedef NS_OPTIONS(NSUInteger, UTMSpiceIOOptions) {
UTMSpiceIOOptionsNone = 0,
UTMSpiceIOOptionsHasAudio = (1 << 0),
UTMSpiceIOOptionsHasClipboardSharing = (1 << 1),
UTMSpiceIOOptionsIsShareReadOnly = (1 << 2),
};
NS_ASSUME_NONNULL_BEGIN
@interface UTMSpiceIO : NSObject<CSConnectionDelegate, QEMUInterface>
@property (nonatomic, readonly, nonnull) UTMConfigurationWrapper* configuration;
@property (nonatomic, readonly, nullable) CSDisplay *primaryDisplay;
@property (nonatomic, readonly, nullable) CSInput *primaryInput;
@property (nonatomic, readonly, nullable) CSPort *primarySerial;
@ -42,7 +47,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, readonly) BOOL isConnected;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfiguration:(UTMConfigurationWrapper *)configuration NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
- (void)changeSharedDirectory:(NSURL *)url;
- (BOOL)startWithError:(NSError * _Nullable *)error;

View File

@ -18,11 +18,12 @@
#import "UTMSpiceIO.h"
#import "UTM-Swift.h"
extern NSString *const kUTMErrorDomain;
NSString *const kUTMErrorDomain = @"com.utmapp.utm";
@interface UTMSpiceIO ()
@property (nonatomic, readwrite, nonnull) UTMConfigurationWrapper* configuration;
@property (nonatomic) NSURL *socketUrl;
@property (nonatomic) UTMSpiceIOOptions options;
@property (nonatomic, readwrite, nullable) CSDisplay *primaryDisplay;
@property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
@property (nonatomic, readwrite, nullable) CSInput *primaryInput;
@ -52,9 +53,10 @@ extern NSString *const kUTMErrorDomain;
return self.mutableSerials;
}
- (instancetype)initWithConfiguration:(UTMConfigurationWrapper *)configuration {
- (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options {
if (self = [super init]) {
self.configuration = configuration;
self.socketUrl = socketUrl;
self.options = options;
self.mutableDisplays = [NSMutableArray array];
self.mutableSerials = [NSMutableArray array];
}
@ -64,10 +66,10 @@ extern NSString *const kUTMErrorDomain;
- (void)initializeSpiceIfNeeded {
if (!self.spiceConnection) {
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:self.configuration.qemuSpiceSocketURL];
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:self.socketUrl];
self.spiceConnection.delegate = self;
self.spiceConnection.audioEnabled = _configuration.qemuHasAudio;
self.spiceConnection.session.shareClipboard = _configuration.qemuHasClipboardSharing;
self.spiceConnection.audioEnabled = (self.options & UTMSpiceIOOptionsHasAudio) == UTMSpiceIOOptionsHasAudio;
self.spiceConnection.session.shareClipboard = (self.options & UTMSpiceIOOptionsHasClipboardSharing) == UTMSpiceIOOptionsHasClipboardSharing;
self.spiceConnection.session.pasteboardDelegate = [UTMPasteboard generalPasteboard];
}
}
@ -235,7 +237,7 @@ extern NSString *const kUTMErrorDomain;
if (self.sharedDirectory) {
UTMLog(@"setting share directory to %@", self.sharedDirectory.path);
[self.sharedDirectory startAccessingSecurityScopedResource];
[self.spiceConnection.session setSharedDirectory:self.sharedDirectory.path readOnly:self.configuration.qemuIsDirectoryShareReadOnly];
[self.spiceConnection.session setSharedDirectory:self.sharedDirectory.path readOnly:(self.options & UTMSpiceIOOptionsIsShareReadOnly) == UTMSpiceIOOptionsIsShareReadOnly];
}
}

View File

@ -1,5 +1,5 @@
//
// Copyright © 2020 osy. All rights reserved.
// Copyright © 2023 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.
@ -15,70 +15,346 @@
//
import Foundation
import Combine
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
extension UTMVirtualMachine: Identifiable {
public var id: UUID {
return registryEntry.uuid
private let kUTMBundleExtension = "utm"
private let kScreenshotPeriodSeconds = 60.0
private let kUTMBundleScreenshotFilename = "screenshot.png"
private let kUTMBundleViewFilename = "view.plist"
/// UTM virtual machine backend
protocol UTMVirtualMachine: AnyObject, Identifiable {
associatedtype Capabilities: UTMVirtualMachineCapabilities
associatedtype Configuration: UTMConfiguration
/// Path where the .utm is stored
var pathUrl: URL { get }
/// True if the .utm is loaded outside of the default storage
///
/// This indicates that we cannot access outside the container.
var isShortcut: Bool { get }
/// The VM is running in disposible mode
///
/// This indicates that changes should not be saved.
var isRunningAsDisposible: Bool { get }
/// Set by caller to handle VM events
var delegate: (any UTMVirtualMachineDelegate)? { get set }
/// Set by caller to handle changes in `config` or `registryEntry`
var onConfigurationChange: (() -> Void)? { get set }
/// Set by caller to handle changes in `state` or `screenshot`
var onStateChange: (() -> Void)? { get set }
/// Configuration for this VM
var config: Configuration { get }
/// Additional configuration on a short lived, per-host basis
///
/// This includes display size, bookmarks to removable drives, etc.
var registryEntry: UTMRegistryEntry { get }
/// Current VM state
var state: UTMVirtualMachineState { get }
/// If non-null, is the most recent screenshot of the running VM
var screenshot: PlatformImage? { get }
static func isVirtualMachine(url: URL) -> Bool
/// Get name of UTM virtual machine from a file
/// - Parameter url: File URL
/// - Returns: The name of the VM
static func virtualMachineName(for url: URL) -> String
/// Get the path of a UTM virtual machine from a name and parent directory
/// - Parameters:
/// - name: VM name
/// - parentUrl: Base directory file URL
/// - Returns: URL of virtual machine
static func virtualMachinePath(for name: String, in parentUrl: URL) -> URL
/// Returns supported capabilities for this backend
static var capabilities: Capabilities { get }
/// Instantiate a new virtual machine
/// - Parameters:
/// - packageUrl: Package where the virtual machine resides
/// - configuration: New virtual machine configuration or nil to load an existing one
/// - isShortcut: Indicate that this package cannot be moved
init(packageUrl: URL, configuration: Configuration?, isShortcut: Bool) throws
/// Discard any changes to configuration by reloading from disk
/// - Parameter packageUrl: URL to reload from, if nil then use the existing package URL
func reload(from packageUrl: URL?) throws
/// Save .utm bundle to disk
///
/// This will create a configuration file and any auxiliary data files if needed.
func save() async throws
/// Called when we save the config
func updateRegistryFromConfig() async throws
/// Called whenever the registry entry changes
func updateConfigFromRegistry()
/// Called when we have a duplicate UUID
/// - Parameters:
/// - uuid: New UUID
/// - name: Optionally change name as well
/// - entry: Optionally copy data from an entry
func changeUuid(to uuid: UUID, name: String?, copyingEntry entry: UTMRegistryEntry?)
/// Starts the VM
/// - Parameter options: Options for startup
func start(options: UTMVirtualMachineStartOptions) async throws
/// Stops the VM
/// - Parameter method: How to handle the stop request
func stop(usingMethod method: UTMVirtualMachineStopMethod) async throws
/// Restarts the VM
func restart() async throws
/// Pauses the VM
func pause() async throws
/// Resumes the VM
func resume() async throws
/// Saves the current VM state
/// - Parameter name: Optional snaphot name (default if nil)
func saveSnapshot(name: String?) async throws
/// Deletes the saved VM state
/// - Parameter name: Optional snaphot name (default if nil)
func deleteSnapshot(name: String?) async throws
/// Restore saved VM state
/// - Parameter name: Optional snaphot name (default if nil)
func restoreSnapshot(name: String?) async throws
/// Request a screenshot of the primary graphics device
/// - Returns: true if successful and the screenshot will be in `screenshot`
@discardableResult func takeScreenshot() async -> Bool
}
/// Supported capabilities for a UTM backend
protocol UTMVirtualMachineCapabilities {
/// The backend supports killing the VM process.
var supportsProcessKill: Bool { get }
/// The backend supports saving/restoring VM state.
var supportsSnapshots: Bool { get }
/// The backend supports taking screenshots.
var supportsScreenshots: Bool { get }
/// The backend supports running without persisting changes.
var supportsDisposibleMode: Bool { get }
/// The backend supports booting into recoveryOS.
var supportsRecoveryMode: Bool { get }
}
/// Delegate for UTMVirtualMachine events
protocol UTMVirtualMachineDelegate: AnyObject {
/// Called when VM state changes
///
/// Will always be called from the main thread.
/// - Parameters:
/// - vm: Virtual machine
/// - state: New state
func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState)
/// Called when VM errors
///
/// Will always be called from the main thread.
/// - Parameters:
/// - vm: Virtual machine
/// - message: Localized error message when supported, English message otherwise
func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String)
/// Called when VM installation updates progress
/// - Parameters:
/// - vm: Virtual machine
/// - progress: Number between 0.0 and 1.0 indiciating installation progress
func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double)
/// Called when VM successfully completes installation
/// - Parameters:
/// - vm: Virtual machine
/// - success: True if installation is successful
func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool)
}
/// Virtual machine state
enum UTMVirtualMachineState {
case stopped
case starting
case started
case pausing
case paused
case resuming
case saving
case restoring
case stopping
}
/// Additional options for VM start
struct UTMVirtualMachineStartOptions: OptionSet {
let rawValue: UInt
/// Boot without persisting any changes.
static let bootDisposibleMode = Self(rawValue: 1 << 0)
/// Boot into recoveryOS (when supported).
static let bootRecovery = Self(rawValue: 1 << 1)
}
/// Method to stop the VM
enum UTMVirtualMachineStopMethod {
/// Sends a request to the guest to shut down gracefully.
case request
/// Sends a hardware power down signal.
case force
/// Terminate the VM process.
case kill
}
// MARK: - Class functions
extension UTMVirtualMachine {
private static var fileManager: FileManager {
FileManager.default
}
static func isVirtualMachine(url: URL) -> Bool {
return url.pathExtension == kUTMBundleExtension
}
static func virtualMachineName(for url: URL) -> String {
(fileManager.displayName(atPath: url.path) as NSString).deletingPathExtension
}
static func virtualMachinePath(for name: String, in parentUrl: URL) -> URL {
let illegalFileNameCharacters = CharacterSet(charactersIn: ",/:\\?%*|\"<>")
let name = name.components(separatedBy: illegalFileNameCharacters).joined(separator: "-")
return parentUrl.appendingPathComponent(name).appendingPathExtension(kUTMBundleExtension)
}
/// Instantiate a new VM from a new configuration
/// - Parameters:
/// - configuration: New configuration
/// - destinationUrl: Directory to store VM
init(newForConfiguration configuration: Self.Configuration, destinationUrl: URL) throws {
let packageUrl = Self.virtualMachinePath(for: configuration.information.name, in: destinationUrl)
try self.init(packageUrl: packageUrl, configuration: configuration, isShortcut: false)
}
}
extension UTMVirtualMachine: ObservableObject {
// MARK: - Snapshots
extension UTMVirtualMachine {
func saveSnapshot(name: String?) async throws {
throw UTMVirtualMachineError.notImplemented
}
@objc extension UTMVirtualMachine {
fileprivate static let gibInMib = 1024
func subscribeToChildren() {
var s: [AnyObject] = []
if let config = config.qemuConfig {
s.append(config.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
})
} else if let config = config.appleConfig {
s.append(config.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
})
func deleteSnapshot(name: String?) async throws {
throw UTMVirtualMachineError.notImplemented
}
s.append(registryEntry.objectWillChange.sink { [weak self] in
func restoreSnapshot(name: String?) async throws {
throw UTMVirtualMachineError.notImplemented
}
}
// MARK: - Screenshot
extension UTMVirtualMachine {
private var isScreenshotSaveEnabled: Bool {
!UserDefaults.standard.bool(forKey: "NoSaveScreenshot")
}
private var screenshotUrl: URL {
pathUrl.appendingPathComponent(kUTMBundleScreenshotFilename)
}
func startScreenshotTimer() -> Timer {
// delete existing screenshot if required
if !isScreenshotSaveEnabled && !isRunningAsDisposible {
try? deleteScreenshot()
}
return Timer.scheduledTimer(withTimeInterval: kScreenshotPeriodSeconds, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
self.objectWillChange.send()
if self.state == .started {
Task { @MainActor in
self.updateConfigFromRegistry()
await self.takeScreenshot()
}
})
anyCancellable = s
}
@MainActor func propertyWillChange() -> Void {
objectWillChange.send()
}
@nonobjc convenience init<Config: UTMConfiguration>(newConfig: Config, destinationURL: URL) {
let packageURL = UTMVirtualMachine.virtualMachinePath(newConfig.information.name, inParentURL: destinationURL)
let configuration = UTMConfigurationWrapper(wrapping: newConfig)
self.init(configurationWrapper: configuration, packageURL: packageURL)
}
}
@objc extension UTMVirtualMachine {
@MainActor func reloadConfiguration() throws {
try config.reload(from: path)
updateConfigFromRegistry()
func loadScreenshot() -> PlatformImage? {
#if canImport(AppKit)
return NSImage(contentsOf: screenshotUrl)
#elseif canImport(UIKit)
return UIImage(contentsOfURL: screenshotUrl)
#endif
}
@MainActor func saveUTM() async throws {
let fileManager = FileManager.default
let existingPath = path
let newPath = UTMVirtualMachine.virtualMachinePath(config.name, inParentURL: existingPath.deletingLastPathComponent())
func saveScreenshot() throws {
guard isScreenshotSaveEnabled && !isRunningAsDisposible else {
return
}
guard let screenshot = screenshot else {
return
}
#if canImport(AppKit)
guard let cgref = screenshot.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return
}
let newrep = NSBitmapImageRep(cgImage: cgref)
newrep.size = screenshot.size
let pngdata = newrep.representation(using: .png, properties: [:])
try pngdata?.write(to: screenshotUrl)
#elseif canImport(UIKit)
try screenshot.pngData()?.write(to: screenshotUrl)
#endif
}
func deleteScreenshot() throws {
try Self.fileManager.removeItem(at: screenshotUrl)
}
@MainActor func takeScreenshot() async -> Bool {
return false
}
}
// MARK: - Save UTM
@MainActor extension UTMVirtualMachine {
func save() async throws {
let existingPath = pathUrl
let newPath = Self.virtualMachinePath(for: config.information.name, in: existingPath.deletingLastPathComponent())
try await config.save(to: existingPath)
let hasRenamed: Bool
if !isShortcut && existingPath.path != newPath.path {
try await Task.detached {
try fileManager.moveItem(at: existingPath, to: newPath)
try Self.fileManager.moveItem(at: existingPath, to: newPath)
}.value
path = newPath
hasRenamed = true
} else {
hasRenamed = false
@ -86,85 +362,168 @@ extension UTMVirtualMachine: ObservableObject {
try await updateRegistryFromConfig()
// reload the config if we renamed in order to point all the URLs to the right path
if hasRenamed {
try config.reload(from: path)
try reload(from: newPath)
}
}
}
/// Called when we save the config
@MainActor func updateRegistryFromConfig() async throws {
if registryEntry.uuid != config.uuid {
changeUuid(to: config.uuid)
// MARK: - Registry functions
@MainActor extension UTMVirtualMachine {
nonisolated func loadRegistry() -> UTMRegistryEntry {
let registryEntry = UTMRegistry.shared.entry(for: self)
// migrate legacy view state
let viewStateUrl = pathUrl.appendingPathComponent(kUTMBundleViewFilename)
registryEntry.migrateUnsafe(viewStateURL: viewStateUrl)
return registryEntry
}
registryEntry.name = config.name
/// Default implementation
func updateRegistryFromConfig() async throws {
try await updateRegistryBasics()
}
/// Called when we save the config
func updateRegistryBasics() async throws {
if registryEntry.uuid != id {
changeUuid(to: id, name: nil, copyingEntry: registryEntry)
}
registryEntry.name = name
let oldPath = registryEntry.package.path
let oldRemoteBookmark = registryEntry.package.remoteBookmark
registryEntry.package = try UTMRegistryEntry.File(url: path)
registryEntry.package = try UTMRegistryEntry.File(url: pathUrl)
if registryEntry.package.path == oldPath {
registryEntry.package.remoteBookmark = oldRemoteBookmark
}
}
/// Called whenever the registry entry changes
@MainActor func updateConfigFromRegistry() {
// implement in subclass
}
/// Called when we have a duplicate UUID
@MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyFromExisting existing: UTMRegistryEntry? = nil) {
if let qemuConfig = config.qemuConfig {
qemuConfig.information.uuid = uuid
if let name = name {
qemuConfig.information.name = name
// MARK: - Identity
extension UTMVirtualMachine {
var id: UUID {
config.information.uuid
}
} else if let appleConfig = config.appleConfig {
appleConfig.information.uuid = uuid
if let name = name {
appleConfig.information.name = name
var name: String {
config.information.name
}
} else {
fatalError("Invalid configuration.")
}
registryEntry = UTMRegistry.shared.entry(for: self)
if let existing = existing {
registryEntry.update(copying: existing)
// MARK: - Errors
enum UTMVirtualMachineError: Error {
case notImplemented
}
extension UTMVirtualMachineError: LocalizedError {
var errorDescription: String? {
switch self {
case .notImplemented:
return NSLocalizedString("Not implemented.", comment: "UTMVirtualMachine")
}
}
}
// MARK: - Bookmark handling
extension URL {
private static var defaultCreationOptions: BookmarkCreationOptions {
#if os(iOS)
return .minimalBookmark
#else
return .withSecurityScope
#endif
// MARK: - Non-asynchronous version (to be removed)
extension UTMVirtualMachine {
func requestVmStart(options: UTMVirtualMachineStartOptions = []) {
Task {
do {
try await start(options: options)
} catch {
await MainActor.run {
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
}
}
private static var defaultResolutionOptions: BookmarkResolutionOptions {
#if os(iOS)
return []
#else
return .withSecurityScope
#endif
func requestVmStop(force: Bool = false) {
Task {
do {
try await stop(usingMethod: force ? .kill : .force)
} catch {
await MainActor.run {
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
}
}
func persistentBookmarkData(isReadyOnly: Bool = false) throws -> Data {
var options = Self.defaultCreationOptions
#if os(macOS)
if isReadyOnly {
options.insert(.securityScopeAllowOnlyReadAccess)
func requestVmReset() {
Task {
do {
try await restart()
} catch {
await MainActor.run {
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
}
#endif
return try self.bookmarkData(options: options,
includingResourceValuesForKeys: nil,
relativeTo: nil)
}
init(resolvingPersistentBookmarkData bookmark: Data) throws {
var stale: Bool = false
try self.init(resolvingBookmarkData: bookmark,
options: Self.defaultResolutionOptions,
bookmarkDataIsStale: &stale)
func requestVmPause(save: Bool = false) {
Task {
do {
try await pause()
if save {
try? await saveSnapshot(name: nil)
}
} catch {
await MainActor.run {
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
}
}
func requestVmSaveState() {
Task {
do {
try await saveSnapshot(name: nil)
} catch {
await MainActor.run {
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
}
}
func requestVmDeleteState() {
Task {
do {
try await deleteSnapshot(name: nil)
} catch {
await MainActor.run {
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
}
}
func requestVmResume() {
Task {
do {
try await resume()
} catch {
await MainActor.run {
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
}
}
func requestGuestPowerDown() {
Task {
do {
try await stop(usingMethod: .request)
} catch {
await MainActor.run {
delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
}
}
}
}
}

View File

@ -143,44 +143,47 @@ struct ContentView: View {
if let action = components.host {
switch action {
case "start":
if let vm = findVM(), vm.wrapped?.state == .vmStopped {
if let vm = findVM(), vm.state == .stopped {
data.run(vm: vm)
}
break
case "stop":
if let vm = findVM(), vm.wrapped?.state == .vmStarted {
vm.wrapped!.requestVmStop(force: true)
if let vm = findVM(), vm.state == .started {
try await vm.wrapped!.stop(usingMethod: .force)
data.stop(vm: vm)
}
break
case "restart":
if let vm = findVM(), vm.wrapped?.state == .vmStarted {
vm.wrapped!.requestVmReset()
if let vm = findVM(), vm.state == .started {
try await vm.wrapped!.restart()
}
break
case "pause":
if let vm = findVM(), vm.wrapped?.state == .vmStarted {
if let vm = findVM(), vm.state == .started {
let shouldSaveOnPause: Bool
if let vm = vm.wrapped as? UTMQemuVirtualMachine {
shouldSaveOnPause = !vm.isRunningAsSnapshot
shouldSaveOnPause = !vm.isRunningAsDisposible
} else {
shouldSaveOnPause = true
}
vm.wrapped!.requestVmPause(save: shouldSaveOnPause)
try await vm.wrapped!.pause()
if shouldSaveOnPause {
try? await vm.wrapped!.saveSnapshot(name: nil)
}
}
case "resume":
if let vm = findVM(), vm.wrapped?.state == .vmPaused {
vm.wrapped!.requestVmResume()
if let vm = findVM(), vm.state == .paused {
try await vm.wrapped!.resume()
}
break
case "sendText":
if let vm = findVM(), vm.wrapped?.state == .vmStarted {
data.automationSendText(to: vm.wrapped!, urlComponents: components)
if let vm = findVM(), vm.state == .started {
data.automationSendText(to: vm, urlComponents: components)
}
break
case "click":
if let vm = findVM(), vm.wrapped?.state == .vmStarted {
data.automationSendMouse(to: vm.wrapped!, urlComponents: components)
if let vm = findVM(), vm.state == .started {
data.automationSendMouse(to: vm, urlComponents: components)
}
break
case "downloadVM":

View File

@ -32,7 +32,7 @@ class UTMDownloadIPSWTask: UTMDownloadTask {
super.init(for: config.system.boot.macRecoveryIpswURL!, named: config.information.name)
}
override func processCompletedDownload(at location: URL) async throws -> UTMVirtualMachine {
override func processCompletedDownload(at location: URL) async throws -> any UTMVirtualMachine {
if !fileManager.fileExists(atPath: cacheUrl.path) {
try fileManager.createDirectory(at: cacheUrl, withIntermediateDirectories: false)
}
@ -45,6 +45,6 @@ class UTMDownloadIPSWTask: UTMDownloadTask {
await MainActor.run {
config.system.boot.macRecoveryIpswURL = cacheIpsw
}
return UTMVirtualMachine(newConfig: config, destinationURL: UTMData.defaultStorageUrl)
return try UTMAppleVirtualMachine(newForConfiguration: config, destinationUrl: UTMData.defaultStorageUrl)
}
}

View File

@ -41,7 +41,7 @@ class UTMDownloadSupportToolsTask: UTMDownloadTask {
super.init(for: Self.supportToolsDownloadUrl, named: name)
}
override func processCompletedDownload(at location: URL) async throws -> UTMVirtualMachine {
override func processCompletedDownload(at location: URL) async throws -> any UTMVirtualMachine {
if !fileManager.fileExists(atPath: supportUrl.path) {
try fileManager.createDirectory(at: supportUrl, withIntermediateDirectories: true)
}
@ -52,13 +52,13 @@ class UTMDownloadSupportToolsTask: UTMDownloadTask {
return try await mountTools()
}
func mountTools() async throws -> UTMVirtualMachine {
func mountTools() async throws -> any UTMVirtualMachine {
for file in await vm.registryEntry.externalDrives.values {
if file.path == supportToolsLocalUrl.path {
throw UTMDownloadSupportToolsTaskError.alreadyMounted
}
}
guard let drive = await vm.qemuConfig.drives.last(where: { $0.isExternal && $0.imageURL == nil }) else {
guard let drive = await vm.config.drives.last(where: { $0.isExternal && $0.imageURL == nil }) else {
throw UTMDownloadSupportToolsTaskError.driveUnavailable
}
try await vm.changeMedium(drive, to: supportToolsLocalUrl)

View File

@ -21,8 +21,8 @@ import Logging
class UTMDownloadTask: NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
let url: URL
let name: String
private var downloadTask: Task<UTMVirtualMachine?, Error>!
private var taskContinuation: CheckedContinuation<UTMVirtualMachine?, Error>?
private var downloadTask: Task<(any UTMVirtualMachine)?, Error>!
private var taskContinuation: CheckedContinuation<(any UTMVirtualMachine)?, Error>?
@MainActor private(set) lazy var pendingVM: UTMPendingVirtualMachine = createPendingVM()
private let kMaxRetries = 5
@ -40,7 +40,7 @@ class UTMDownloadTask: NSObject, URLSessionDelegate, URLSessionDownloadDelegate
/// Called by subclass when download is completed
/// - Parameter location: Downloaded file location
/// - Returns: Processed UTM virtual machine
func processCompletedDownload(at location: URL) async throws -> UTMVirtualMachine {
func processCompletedDownload(at location: URL) async throws -> any UTMVirtualMachine {
throw "Not Implemented"
}
@ -147,7 +147,7 @@ class UTMDownloadTask: NSObject, URLSessionDelegate, URLSessionDownloadDelegate
/// Starts the download
/// - Returns: Completed download or nil if canceled
func download() async throws -> UTMVirtualMachine? {
func download() async throws -> (any UTMVirtualMachine)? {
/// begin the download
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
downloadTask = Task.detached { [self] in

View File

@ -35,7 +35,7 @@ class UTMDownloadVMTask: UTMDownloadTask {
return nameWithoutZIP
}
override func processCompletedDownload(at location: URL) async throws -> UTMVirtualMachine {
override func processCompletedDownload(at location: URL) async throws -> any UTMVirtualMachine {
let tempDir = fileManager.temporaryDirectory
let originalFilename = url.lastPathComponent
let downloadedZip = tempDir.appendingPathComponent(originalFilename)
@ -51,11 +51,8 @@ class UTMDownloadVMTask: UTMDownloadTask {
/// remove the downloaded ZIP file
try fileManager.removeItem(at: downloadedZip)
/// load the downloaded VM into the UI
if let vm = UTMVirtualMachine(url: utmURL) {
return vm
} else {
throw CreateUTMFailed()
}
let vm = try await VMData(url: utmURL)
return await vm.wrapped!
} catch {
logger.error(Logger.Message(stringLiteral: error.localizedDescription))
if let fileURL = fileURL {

View File

@ -73,6 +73,6 @@ fileprivate struct WrappedVMDetailsView: View {
struct UTMUnavailableVMView_Previews: PreviewProvider {
static var previews: some View {
UTMUnavailableVMView(vm: VMData(wrapping: UTMWrappedVirtualMachine(bookmark: Data(), name: "Wrapped VM", path: URL(fileURLWithPath: "/"))))
UTMUnavailableVMView(vm: VMData(from: UTMRegistryEntry.empty))
}
}

View File

@ -113,6 +113,6 @@ struct Logo: View {
struct VMCardView_Previews: PreviewProvider {
static var previews: some View {
VMCardView(vm: VMData(wrapping: UTMVirtualMachine(newConfig: UTMQemuConfiguration(), destinationURL: URL(fileURLWithPath: "/"))))
VMCardView(vm: VMData(from: .empty))
}
}

View File

@ -64,31 +64,29 @@ struct VMContextMenuModifier: ViewModifier {
#if os(macOS) && arch(arm64)
if #available(macOS 13, *), let appleConfig = vm.config as? UTMAppleConfiguration, appleConfig.system.boot.operatingSystem == .macOS {
Button {
appleConfig.system.boot.startUpFromMacOSRecovery = true
data.run(vm: vm)
data.run(vm: vm, options: .bootRecovery)
} label: {
Label("Run Recovery", systemImage: "play.fill")
}.help("Boot into recovery mode.")
}
#endif
if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
if let _ = vm.wrapped as? UTMQemuVirtualMachine {
Button {
qemuVM.isRunningAsSnapshot = true
data.run(vm: vm)
data.run(vm: vm, options: .bootDisposibleMode)
} label: {
Label("Run without saving changes", systemImage: "play")
}.help("Run the VM in the foreground, without saving data changes to disk.")
}
#if os(iOS)
if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
if let qemuConfig = vm.config as? UTMQemuConfiguration {
Button {
qemuVM.isGuestToolsInstallRequested = true
qemuConfig.qemu.isGuestToolsInstallRequested = true
} label: {
Label("Install Windows Guest Tools…", systemImage: "wrench.and.screwdriver")
}.help("Download and mount the guest tools for Windows.")
.disabled(qemuVM.isGuestToolsInstallRequested)
.disabled(qemuConfig.qemu.isGuestToolsInstallRequested)
}
#endif
@ -146,7 +144,7 @@ struct VMContextMenuModifier: ViewModifier {
showSharePopup.toggle()
}
})
.onChange(of: (vm.wrapped as? UTMQemuVirtualMachine)?.isGuestToolsInstallRequested) { newValue in
.onChange(of: (vm.config as? UTMQemuConfiguration)?.qemu.isGuestToolsInstallRequested) { newValue in
if newValue == true {
data.busyWorkAsync {
try await data.mountSupportTools(for: vm.wrapped as! UTMQemuVirtualMachine)

View File

@ -63,15 +63,15 @@ struct VMDetailsView: View {
}.padding([.leading, .trailing])
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: appleVM, config: appleVM.appleConfig, registryEntry: appleVM.registryEntry)
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
.padding([.leading, .trailing, .bottom])
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
.padding([.leading, .trailing, .bottom])
}
#else
let qemuVM = vm as! UTMQemuVirtualMachine
VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
.padding([.leading, .trailing, .bottom])
#endif
} else {
@ -84,13 +84,13 @@ struct VMDetailsView: View {
}
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: appleVM, config: appleVM.appleConfig, registryEntry: appleVM.registryEntry)
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
}
#else
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
#endif
}.padding([.leading, .trailing, .bottom])
}
@ -228,14 +228,14 @@ struct Details: View {
plainLabel("Serial (Client)", systemImage: "network")
Spacer()
let address = "\(serial.tcpHostAddress ?? "example.com"):\(serial.tcpPort ?? 1234)"
OptionalSelectableText(vm.wrapped?.state == .vmStarted ? address : nil)
OptionalSelectableText(vm.state == .started ? address : nil)
}
} else if serial.mode == .tcpServer {
HStack {
plainLabel("Serial (Server)", systemImage: "network")
Spacer()
let address = "\(serial.tcpPort ?? 1234)"
OptionalSelectableText(vm.wrapped?.state == .vmStarted ? address : nil)
OptionalSelectableText(vm.state == .started ? address : nil)
}
}
#if os(macOS)
@ -302,7 +302,7 @@ struct VMDetailsView_Previews: PreviewProvider {
@State static private var config = UTMQemuConfiguration()
static var previews: some View {
VMDetailsView(vm: VMData(wrapping: UTMVirtualMachine(newConfig: config, destinationURL: URL(fileURLWithPath: ""))))
VMDetailsView(vm: VMData(from: .empty))
.onAppear {
config.sharing.directoryShareMode = .webdav
var drive = UTMQemuConfigurationDrive()

View File

@ -17,7 +17,7 @@
import SwiftUI
struct VMRemovableDrivesView: View {
@ObservedObject var vm: UTMQemuVirtualMachine
@ObservedObject var vm: VMData
@ObservedObject var config: UTMQemuConfiguration
@EnvironmentObject private var data: UTMData
@State private var shareDirectoryFileImportPresented: Bool = false
@ -26,13 +26,17 @@ struct VMRemovableDrivesView: View {
@State private var workaroundFileImporterBug: Bool = false
@State private var currentDrive: UTMQemuConfigurationDrive?
private var qemuVM: UTMQemuVirtualMachine! {
vm.wrapped as? UTMQemuVirtualMachine
}
var fileManager: FileManager {
FileManager.default
}
// Is a shared directory set?
private var hasSharedDir: Bool { vm.sharedDirectoryURL != nil }
private var hasSharedDir: Bool { qemuVM.sharedDirectoryURL != nil }
@ViewBuilder private var shareMenuActions: some View {
Button(action: { shareDirectoryFileImportPresented.toggle() }) {
@ -55,7 +59,7 @@ struct VMRemovableDrivesView: View {
Group {
let mode = vm.config.qemuConfig!.sharing.directoryShareMode
let mode = config.sharing.directoryShareMode
if mode != .none {
HStack {
title
@ -64,13 +68,13 @@ struct VMRemovableDrivesView: View {
Menu {
shareMenuActions
} label: {
SharedPath(path: vm.sharedDirectoryURL?.path)
SharedPath(path: qemuVM.sharedDirectoryURL?.path)
}.fixedSize()
} else {
Button("Browse…", action: { shareDirectoryFileImportPresented.toggle() })
}
}.fileImporter(isPresented: $shareDirectoryFileImportPresented, allowedContentTypes: [.folder], onCompletion: selectShareDirectory)
.disabled(mode == .virtfs && vm.state != .vmStopped)
.disabled(mode == .virtfs && vm.state != .stopped)
}
ForEach(config.drives.filter { $0.isExternal }) { drive in
HStack {
@ -106,14 +110,14 @@ struct VMRemovableDrivesView: View {
}
}
// Eject button
if vm.externalImageURL(for: drive) != nil {
if qemuVM.externalImageURL(for: drive) != nil {
Button(action: { clearRemovableImage(forDrive: drive) }, label: {
Label("Clear", systemImage: "eject")
})
}
} label: {
DriveLabel(drive: drive, isInserted: vm.externalImageURL(for: drive) != nil)
}.disabled(vm.hasSaveState)
DriveLabel(drive: drive, isInserted: qemuVM.externalImageURL(for: drive) != nil)
}.disabled(vm.hasSuspendState)
Spacer()
// Disk image path, or (empty)
Text(pathFor(drive))
@ -152,7 +156,7 @@ struct VMRemovableDrivesView: View {
}
private func pathFor(_ drive: UTMQemuConfigurationDrive) -> String {
if let url = vm.externalImageURL(for: drive) {
if let url = qemuVM.externalImageURL(for: drive) {
return url.lastPathComponent
} else {
return NSLocalizedString("(empty)", comment: "A removable drive that has no image file inserted.")
@ -179,7 +183,7 @@ struct VMRemovableDrivesView: View {
data.busyWorkAsync {
switch result {
case .success(let url):
try await vm.changeSharedDirectory(to: url)
try await qemuVM.changeSharedDirectory(to: url)
break
case .failure(let err):
throw err
@ -189,7 +193,7 @@ struct VMRemovableDrivesView: View {
private func clearShareDirectory() {
data.busyWorkAsync {
await vm.clearSharedDirectory()
await qemuVM.clearSharedDirectory()
}
}
@ -197,7 +201,7 @@ struct VMRemovableDrivesView: View {
data.busyWorkAsync {
switch result {
case .success(let url):
try await vm.changeMedium(drive, to: url)
try await qemuVM.changeMedium(drive, to: url)
break
case .failure(let err):
throw err
@ -207,7 +211,7 @@ struct VMRemovableDrivesView: View {
private func clearRemovableImage(forDrive drive: UTMQemuConfigurationDrive) {
data.busyWorkAsync {
try await vm.eject(drive)
try await qemuVM.eject(drive)
}
}
}
@ -216,7 +220,7 @@ struct VMRemovableDrivesView_Previews: PreviewProvider {
@State static private var config = UTMQemuConfiguration()
static var previews: some View {
VMDetailsView(vm: VMData(wrapping: UTMVirtualMachine(newConfig: config, destinationURL: URL(fileURLWithPath: ""))))
VMDetailsView(vm: VMData(from: .empty))
.onAppear {
config.sharing.directoryShareMode = .webdav
var drive = UTMQemuConfigurationDrive()

View File

@ -30,9 +30,6 @@
#include "UTMJailbreak.h"
#include "UTMLogging.h"
#include "UTMLegacyViewState.h"
#include "UTMVirtualMachine.h"
#include "UTMVirtualMachine-Protected.h"
#include "UTMVirtualMachineDelegate.h"
#include "UTMSpiceIO.h"
#if TARGET_OS_IPHONE
#include "UTMLocationManager.h"

View File

@ -70,7 +70,7 @@ struct AlertMessage: Identifiable {
#if os(macOS)
/// View controller for every VM currently active
var vmWindows: [UTMVirtualMachine: Any] = [:]
var vmWindows: [VMData: Any] = [:]
#else
/// View controller for currently active VM
var vmVC: Any?
@ -129,13 +129,11 @@ struct AlertMessage: Identifiable {
guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
continue
}
guard UTMVirtualMachine.isVirtualMachine(url: file) else {
guard UTMQemuVirtualMachine.isVirtualMachine(url: file) else {
continue
}
await Task.yield()
let vm = UTMVirtualMachine(url: file)
if let vm = vm {
let vm = VMData(wrapping: vm)
if let vm = try? VMData(url: file) {
if uuidHasCollision(with: vm, in: list) {
if let index = list.firstIndex(where: { !$0.isLoaded && $0.id == vm.id }) {
// we have a stale VM with the same UUID, so we replace that entry with this one
@ -189,7 +187,11 @@ struct AlertMessage: Identifiable {
return nil
}
let vm = VMData(from: entry)
try? vm.load()
do {
try vm.load()
} catch {
logger.error("Error loading '\(entry.uuid)': \(error)")
}
return vm
}
}
@ -201,8 +203,8 @@ struct AlertMessage: Identifiable {
if let files = defaults.array(forKey: "VMList") as? [String] {
virtualMachines = files.uniqued().compactMap({ file in
let url = documentsURL.appendingPathComponent(file, isDirectory: true)
if let wrapped = UTMVirtualMachine(url: url) {
return VMData(wrapping: wrapped)
if let vm = try? VMData(url: url) {
return vm
} else {
return nil
}
@ -310,7 +312,7 @@ struct AlertMessage: Identifiable {
let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
for i in 1..<1000 {
let name = nameForId(i)
let file = UTMVirtualMachine.virtualMachinePath(name, inParentURL: documentsURL)
let file = UTMQemuVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
if !fileManager.fileExists(atPath: file.path) {
return name
}
@ -385,11 +387,10 @@ struct AlertMessage: Identifiable {
} catch {
// if we can't discard changes, recreate the VM from scratch
let path = vm.pathUrl
guard let newWrapped = UTMVirtualMachine(url: path) else {
guard let newVM = try? VMData(url: path) else {
logger.debug("Cannot create new object for \(path.path)")
throw origError
}
let newVM = VMData(wrapping: newWrapped)
let index = listRemove(vm: vm)
listAdd(vm: newVM, at: index)
listSelect(vm: newVM)
@ -402,9 +403,9 @@ struct AlertMessage: Identifiable {
/// - Parameter vm: VM configuration to discard
func discardChanges(for vm: VMData) throws {
if let wrapped = vm.wrapped {
try wrapped.reloadConfiguration()
try wrapped.reload(from: nil)
if uuidHasCollision(with: vm) {
wrapped.changeUuid(to: UUID())
wrapped.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
}
}
}
@ -416,7 +417,7 @@ struct AlertMessage: Identifiable {
guard !virtualMachines.contains(where: { !$0.isShortcut && $0.config?.information.name == config.information.name }) else {
throw UTMDataError.virtualMachineAlreadyExists
}
let vm = VMData(creatingFromConfig: config, destinationUrl: Self.defaultStorageUrl)
let vm = try VMData(creatingFromConfig: config, destinationUrl: Self.defaultStorageUrl)
try await save(vm: vm)
listAdd(vm: vm)
listSelect(vm: vm)
@ -445,14 +446,13 @@ struct AlertMessage: Identifiable {
/// - Returns: The new VM
@discardableResult func clone(vm: VMData) async throws -> VMData {
let newName: String = newDefaultVMName(base: vm.detailsTitleLabel)
let newPath = UTMVirtualMachine.virtualMachinePath(newName, inParentURL: documentsURL)
let newPath = UTMQemuVirtualMachine.virtualMachinePath(for: newName, in: documentsURL)
try copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
guard let wrapped = UTMVirtualMachine(url: newPath) else {
guard let newVM = try? VMData(url: newPath) else {
throw UTMDataError.cloneFailed
}
wrapped.changeUuid(to: UUID(), name: newName)
let newVM = VMData(wrapping: wrapped)
newVM.wrapped!.changeUuid(to: UUID(), name: newName, copyingEntry: nil)
try await newVM.save()
var index = virtualMachines.firstIndex(of: vm)
if index != nil {
@ -481,13 +481,10 @@ struct AlertMessage: Identifiable {
/// - url: Location to move to (must be writable)
func move(vm: VMData, to url: URL) async throws {
try export(vm: vm, to: url)
guard let wrapped = UTMVirtualMachine(url: url) else {
guard let newVM = try? VMData(url: url) else {
throw UTMDataError.shortcutCreationFailed
}
wrapped.isShortcut = true
try await wrapped.updateRegistryFromConfig()
try await wrapped.accessShortcut()
let newVM = VMData(wrapping: wrapped)
try await newVM.wrapped!.updateRegistryFromConfig()
let oldSelected = selectedVM
let index = try await delete(vm: vm, alsoRegistry: false)
@ -592,28 +589,25 @@ struct AlertMessage: Identifiable {
return
}
// check if VM is valid
guard let _ = UTMVirtualMachine(url: url) else {
guard let _ = try? VMData(url: url) else {
throw UTMDataError.importFailed
}
let wrapped: UTMVirtualMachine?
let vm: VMData?
if (fileBasePath.resolvingSymlinksInPath().path == documentsURL.appendingPathComponent("Inbox", isDirectory: true).path) {
logger.info("moving from Inbox")
try fileManager.moveItem(at: url, to: dest)
wrapped = UTMVirtualMachine(url: dest)
vm = try VMData(url: dest)
} else if asShortcut {
logger.info("loading as a shortcut")
wrapped = UTMVirtualMachine(url: url)
wrapped?.isShortcut = true
try await wrapped?.accessShortcut()
vm = try VMData(url: url)
} else {
logger.info("copying to Documents")
try fileManager.copyItem(at: url, to: dest)
wrapped = UTMVirtualMachine(url: dest)
vm = try VMData(url: dest)
}
guard let wrapped = wrapped else {
guard let vm = vm else {
throw UTMDataError.importParseFailed
}
let vm = VMData(wrapping: wrapped)
listAdd(vm: vm)
listSelect(vm: vm)
}
@ -678,7 +672,7 @@ struct AlertMessage: Identifiable {
func mountSupportTools(for vm: UTMQemuVirtualMachine) async throws {
let task = UTMDownloadSupportToolsTask(for: vm)
if task.hasExistingSupportTools {
vm.isGuestToolsInstallRequested = false
vm.config.qemu.isGuestToolsInstallRequested = false
_ = try await task.mountTools()
} else {
listAdd(pendingVM: task.pendingVM)
@ -687,7 +681,7 @@ struct AlertMessage: Identifiable {
} catch {
showErrorAlert(message: error.localizedDescription)
}
vm.isGuestToolsInstallRequested = false
vm.config.qemu.isGuestToolsInstallRequested = false
listRemove(pendingVM: task.pendingVM)
}
}
@ -750,8 +744,7 @@ struct AlertMessage: Identifiable {
guard let vm = vm.wrapped else {
return
}
let previous = vm.registryEntry
vm.changeUuid(to: UUID(), copyFromExisting: previous)
vm.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
}
// MARK: - Other utility functions
@ -806,7 +799,7 @@ struct AlertMessage: Identifiable {
/// - Parameters:
/// - vm: VM to send text to
/// - components: Data (see UTM Wiki for details)
func automationSendText(to vm: UTMVirtualMachine, urlComponents components: URLComponents) {
func automationSendText(to vm: VMData, urlComponents components: URLComponents) {
guard let queryItems = components.queryItems else { return }
guard let text = queryItems.first(where: { $0.name == "text" })?.value else { return }
#if os(macOS)
@ -820,9 +813,9 @@ struct AlertMessage: Identifiable {
/// - Parameters:
/// - vm: VM to send mouse/tablet coordinates to
/// - components: Data (see UTM Wiki for details)
func automationSendMouse(to vm: UTMVirtualMachine, urlComponents components: URLComponents) {
guard let qemuVm = vm as? UTMQemuVirtualMachine else { return } // FIXME: implement for Apple VM
guard !qemuVm.qemuConfig.displays.isEmpty else { return }
func automationSendMouse(to vm: VMData, urlComponents components: URLComponents) {
guard let qemuVm = vm.wrapped as? UTMQemuVirtualMachine else { return } // FIXME: implement for Apple VM
guard !qemuVm.config.displays.isEmpty else { return }
guard let queryItems = components.queryItems else { return }
/// Parse targeted position
var x: CGFloat? = nil
@ -857,7 +850,7 @@ struct AlertMessage: Identifiable {
}
/// All parameters parsed, perform the click
#if os(macOS)
tryClickAtPoint(vm: qemuVm, point: point, button: button)
tryClickAtPoint(vm: vm, point: point, button: button)
#else
tryClickAtPoint(point: point, button: button)
#endif

View File

@ -264,6 +264,12 @@ extension UIImage {
}
#endif
#if canImport(AppKit)
typealias PlatformImage = NSImage
#elseif canImport(UIKit)
typealias PlatformImage = UIImage
#endif
#if os(macOS)
enum FakeKeyboardType : Int {
case asciiCapable
@ -321,3 +327,41 @@ struct Setting<T> {
self.keyName = keyName
}
}
// MARK: - Bookmark handling
extension URL {
private static var defaultCreationOptions: BookmarkCreationOptions {
#if os(iOS)
return .minimalBookmark
#else
return .withSecurityScope
#endif
}
private static var defaultResolutionOptions: BookmarkResolutionOptions {
#if os(iOS)
return []
#else
return .withSecurityScope
#endif
}
func persistentBookmarkData(isReadyOnly: Bool = false) throws -> Data {
var options = Self.defaultCreationOptions
#if os(macOS)
if isReadyOnly {
options.insert(.securityScopeAllowOnlyReadAccess)
}
#endif
return try self.bookmarkData(options: options,
includingResourceValuesForKeys: nil,
relativeTo: nil)
}
init(resolvingPersistentBookmarkData bookmark: Data) throws {
var stale: Bool = false
try self.init(resolvingBookmarkData: bookmark,
options: Self.defaultResolutionOptions,
bookmarkDataIsStale: &stale)
}
}

View File

@ -20,7 +20,7 @@ import SwiftUI
/// Model wrapping a single UTMVirtualMachine for use in views
@MainActor class VMData: ObservableObject {
/// Underlying virtual machine
private(set) var wrapped: UTMVirtualMachine? {
private(set) var wrapped: (any UTMVirtualMachine)? {
willSet {
objectWillChange.send()
}
@ -32,13 +32,13 @@ import SwiftUI
/// Virtual machine configuration
var config: (any UTMConfiguration)? {
wrapped?.config.wrappedValue as? (any UTMConfiguration)
wrapped?.config
}
/// Current path of the VM
var pathUrl: URL {
if let wrapped = wrapped {
return wrapped.path
return wrapped.pathUrl
} else if let registryEntry = registryEntry {
return registryEntry.package.url
} else {
@ -63,6 +63,12 @@ import SwiftUI
/// This is a workaround for SwiftUI bugs not hiding deleted elements.
@Published var isDeleted: Bool = false
/// Copy from wrapped VM
@Published var state: UTMVirtualMachineState = .stopped
/// Copy from wrapped VM
@Published var screenshot: PlatformImage?
/// Allows changes in the config, registry, and VM to be reflected
private var observers: [AnyCancellable] = []
@ -73,12 +79,19 @@ import SwiftUI
/// Create a VM from an existing object
/// - Parameter vm: VM to wrap
convenience init(wrapping vm: UTMVirtualMachine) {
convenience init(wrapping vm: any UTMVirtualMachine) {
self.init()
self.wrapped = vm
subscribeToChildren()
}
/// Attempt to a new wrapped UTM VM from a file path
/// - Parameter url: File path
convenience init(url: URL) throws {
self.init()
try load(from: url)
}
/// Create a new wrapped UTM VM from a registry entry
/// - Parameter registryEntry: Registry entry
convenience init(from registryEntry: UTMRegistryEntry) {
@ -114,42 +127,62 @@ import SwiftUI
/// Create a new VM from a configuration
/// - Parameter config: Configuration to create new VM
convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) {
convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) throws {
self.init()
if config is UTMQemuConfiguration {
wrapped = UTMQemuVirtualMachine(newConfig: config, destinationURL: destinationUrl)
if let qemuConfig = config as? UTMQemuConfiguration {
wrapped = try UTMQemuVirtualMachine(newForConfiguration: qemuConfig, destinationUrl: destinationUrl)
}
#if os(macOS)
if config is UTMAppleConfiguration {
wrapped = UTMAppleVirtualMachine(newConfig: config, destinationURL: destinationUrl)
if let appleConfig = config as? UTMAppleConfiguration {
wrapped = try UTMAppleVirtualMachine(newForConfiguration: appleConfig, destinationUrl: destinationUrl)
}
#endif
subscribeToChildren()
}
/// Loads the VM from file
/// Loads the VM
///
/// If the VM is already loaded, it will return true without doing anything.
/// - Parameter url: URL to load from
/// - Returns: If load was successful
func load() throws {
try load(from: pathUrl)
}
/// Loads the VM from a path
///
/// If the VM is already loaded, it will return true without doing anything.
/// - Parameter url: URL to load from
/// - Returns: If load was successful
private func load(from url: URL) throws {
guard !isLoaded else {
return
}
guard let vm = UTMVirtualMachine(url: pathUrl) else {
var loaded: (any UTMVirtualMachine)?
let config = try UTMQemuConfiguration.load(from: url)
if let qemuConfig = config as? UTMQemuConfiguration {
loaded = try UTMQemuVirtualMachine(packageUrl: url, configuration: qemuConfig, isShortcut: isShortcut)
}
#if os(macOS)
if let appleConfig = config as? UTMAppleConfiguration {
loaded = try UTMAppleVirtualMachine(packageUrl: url, configuration: appleConfig, isShortcut: isShortcut)
}
#endif
guard let vm = loaded else {
throw VMDataError.virtualMachineNotLoaded
}
vm.isShortcut = isShortcut
if let oldEntry = registryEntry, oldEntry.uuid != vm.registryEntry.uuid {
if uuidUnknown {
// legacy VMs don't have UUID stored so we made a fake UUID
UTMRegistry.shared.remove(entry: oldEntry)
} else {
// persistent uuid does not match indicating a cloned or legacy VM with a duplicate UUID
vm.changeUuid(to: oldEntry.uuid, copyFromExisting: oldEntry)
vm.changeUuid(to: oldEntry.uuid, name: nil, copyingEntry: oldEntry)
}
}
wrapped = vm
uuidUnknown = false
subscribeToChildren()
}
/// Saves the VM to file
@ -157,39 +190,42 @@ import SwiftUI
guard let wrapped = wrapped else {
throw VMDataError.virtualMachineNotLoaded
}
try await wrapped.saveUTM()
try await wrapped.save()
}
/// Listen to changes in the underlying object and propogate upwards
private func subscribeToChildren() {
var s: [AnyCancellable] = []
if let config = config as? UTMQemuConfiguration {
s.append(config.objectWillChange.sink { [weak self] in
if let wrapped = wrapped {
wrapped.onConfigurationChange = { [weak self] in
self?.subscribeToChildren()
self?.objectWillChange.send()
}
wrapped.onStateChange = { [weak self] in
guard let self = self else {
return
}
Task { @MainActor in
self.state = wrapped.state
self.screenshot = wrapped.screenshot
}
}
}
if let qemuConfig = wrapped?.config as? UTMQemuConfiguration {
s.append(qemuConfig.objectWillChange.sink { [weak self] _ in
self?.objectWillChange.send()
})
}
#if os(macOS)
if let config = config as? UTMAppleConfiguration {
s.append(config.objectWillChange.sink { [weak self] in
if let appleConfig = wrapped?.config as? UTMAppleConfiguration {
s.append(appleConfig.objectWillChange.sink { [weak self] _ in
self?.objectWillChange.send()
})
}
#endif
if let registryEntry = registryEntry {
s.append(registryEntry.objectWillChange.sink { [weak self] in
guard let self = self else {
return
}
self.objectWillChange.send()
self.wrapped?.updateConfigFromRegistry()
})
}
// observe KVO publisher for state changes
if let wrapped = wrapped {
s.append(wrapped.publisher(for: \.state).sink { [weak self] _ in
self?.objectWillChange.send()
})
s.append(wrapped.publisher(for: \.screenshot).sink { [weak self] _ in
self?.objectWillChange.send()
})
}
@ -223,7 +259,7 @@ extension VMData: Identifiable {
extension VMData: Equatable {
static func == (lhs: VMData, rhs: VMData) -> Bool {
if lhs.isLoaded && rhs.isLoaded {
return lhs.wrapped == rhs.wrapped
return lhs.wrapped === rhs.wrapped
}
if let lhsEntry = lhs.registryEntryWrapped, let rhsEntry = rhs.registryEntryWrapped {
return lhsEntry == rhsEntry
@ -234,7 +270,7 @@ extension VMData: Equatable {
extension VMData: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(wrapped)
hasher.combine(pathUrl)
hasher.combine(registryEntryWrapped)
hasher.combine(isDeleted)
}
@ -244,14 +280,10 @@ extension VMData: Hashable {
extension VMData {
/// True if the .utm is loaded outside of the default storage
var isShortcut: Bool {
if let wrapped = wrapped {
return wrapped.isShortcut
} else {
let defaultStorageUrl = UTMData.defaultStorageUrl.standardizedFileURL
let parentUrl = pathUrl.deletingLastPathComponent().standardizedFileURL
return parentUrl != defaultStorageUrl
}
}
/// VM is loaded
var isLoaded: Bool {
@ -260,28 +292,22 @@ extension VMData {
/// VM is stopped
var isStopped: Bool {
if let state = wrapped?.state {
return state == .vmStopped || state == .vmPaused
} else {
return true
}
state == .stopped || state == .paused
}
/// VM can be modified
var isModifyAllowed: Bool {
if let state = wrapped?.state {
return state == .vmStopped
} else {
return false
}
state == .stopped
}
/// Display VM as "busy" for UI elements
var isBusy: Bool {
wrapped?.state == .vmPausing ||
wrapped?.state == .vmResuming ||
wrapped?.state == .vmStarting ||
wrapped?.state == .vmStopping
state == .pausing ||
state == .resuming ||
state == .starting ||
state == .stopping ||
state == .saving ||
state == .resuming
}
/// VM has been suspended before
@ -292,12 +318,6 @@ extension VMData {
// MARK: - Home UI elements
extension VMData {
#if os(macOS)
typealias PlatformImage = NSImage
#else
typealias PlatformImage = UIImage
#endif
/// Unavailable string
private var unavailable: String {
NSLocalizedString("Unavailable", comment: "VMData")
@ -367,35 +387,34 @@ extension VMData {
/// Display current VM state as a string for UI elements
var stateLabel: String {
guard let state = wrapped?.state else {
return unavailable
}
switch state {
case .vmStopped:
case .stopped:
if registryEntry?.hasSaveState == true {
return NSLocalizedString("Suspended", comment: "VMData");
} else {
return NSLocalizedString("Stopped", comment: "VMData");
}
case .vmStarting:
case .starting:
return NSLocalizedString("Starting", comment: "VMData")
case .vmStarted:
case .started:
return NSLocalizedString("Started", comment: "VMData")
case .vmPausing:
case .pausing:
return NSLocalizedString("Pausing", comment: "VMData")
case .vmPaused:
case .paused:
return NSLocalizedString("Paused", comment: "VMData")
case .vmResuming:
case .resuming:
return NSLocalizedString("Resuming", comment: "VMData")
case .vmStopping:
case .stopping:
return NSLocalizedString("Stopping", comment: "VMData")
@unknown default:
fatalError()
case .saving:
return NSLocalizedString("Saving", comment: "VMData")
case .restoring:
return NSLocalizedString("Restoring", comment: "VMData")
}
}
/// If non-null, is the most recent screenshot image of the running VM
var screenshotImage: PlatformImage? {
wrapped?.screenshot?.image
wrapped?.screenshot
}
}

View File

@ -16,7 +16,6 @@
#import "VMDisplayMetalViewController+Keyboard.h"
#import "UTMLogging.h"
#import "UTMVirtualMachine.h"
#import "VMKeyboardView.h"
#import "VMKeyboardButton.h"
#import "UTM-Swift.h"

View File

@ -21,7 +21,6 @@
#import "VMDisplayMetalViewController+Pencil.h"
#import "VMDisplayMetalViewController+Gamepad.h"
#import "VMKeyboardView.h"
#import "UTMVirtualMachine.h"
#import "UTMLogging.h"
#import "CSDisplay.h"
#import "UTM-Swift.h"

View File

@ -17,7 +17,6 @@
import Foundation
@objc protocol VMDisplayViewControllerDelegate {
var vmState: UTMVMState { get }
var qemuInputLegacy: Bool { get }
var qemuDisplayUpscaler: MTLSamplerMinMagFilter { get }
var qemuDisplayDownscaler: MTLSamplerMinMagFilter { get }

View File

@ -18,7 +18,7 @@ import Foundation
import SwiftUI
extension UTMData {
func run(vm: VMData) {
func run(vm: VMData, options: UTMVirtualMachineStartOptions = []) {
guard let wrapped = vm.wrapped else {
return
}
@ -30,7 +30,7 @@ extension UTMData {
guard let wrapped = vm.wrapped else {
return
}
if wrapped.hasSaveState {
if wrapped.registryEntry.hasSaveState {
wrapped.requestVmDeleteState()
}
}

View File

@ -24,12 +24,12 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
@Binding var state: VMWindowState
var vmStateCancellable: AnyCancellable?
var vmState: UTMVMState {
var vmState: UTMVirtualMachineState {
vm.state
}
var vmConfig: UTMQemuConfiguration! {
vm.config.qemuConfig
var vmConfig: UTMQemuConfiguration {
vm.config
}
@MainActor var qemuInputLegacy: Bool {
@ -111,7 +111,7 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
}
func displayDidAppear() {
if vm.state == .vmStopped {
if vm.state == .stopped {
vm.requestVmStart()
}
}
@ -147,20 +147,18 @@ struct VMDisplayHostedView: UIViewControllerRepresentable {
mvc.setDisplayScaling(state.displayScale, origin: state.displayOrigin)
vc = mvc
case .serial(let serial, let id):
let style = vm.qemuConfig.serials[id].terminal
let style = vm.config.serials[id].terminal
vc = VMDisplayTerminalViewController(port: serial, style: style)
vc.delegate = context.coordinator
}
context.coordinator.vmStateCancellable = session.$vmState.sink { vmState in
switch vmState {
case .vmStopped, .vmPaused:
case .stopped, .paused:
vc.enterSuspended(isBusy: false)
case .vmPausing, .vmStopping, .vmStarting, .vmResuming:
case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
vc.enterSuspended(isBusy: true)
case .vmStarted:
case .started:
vc.enterLive()
@unknown default:
break
}
}
return vc

View File

@ -24,11 +24,11 @@ import SwiftUI
let vm: UTMQemuVirtualMachine
var qemuConfig: UTMQemuConfiguration! {
vm.config.qemuConfig
var qemuConfig: UTMQemuConfiguration {
vm.config
}
@Published var vmState: UTMVMState = .vmStopped
@Published var vmState: UTMVirtualMachineState = .stopped
@Published var fatalError: String?
@ -123,10 +123,10 @@ import SwiftUI
}
extension VMSessionState: UTMVirtualMachineDelegate {
nonisolated func virtualMachine(_ vm: UTMVirtualMachine, didTransitionTo state: UTMVMState) {
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
Task { @MainActor in
vmState = state
if state == .vmStopped {
if state == .stopped {
#if !WITH_QEMU_TCI
clearDevices()
#endif
@ -134,11 +134,19 @@ extension VMSessionState: UTMVirtualMachineDelegate {
}
}
nonisolated func virtualMachine(_ vm: UTMVirtualMachine, didErrorWithMessage message: String) {
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
Task { @MainActor in
fatalError = message
}
}
func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
}
func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
}
}
extension VMSessionState: UTMSpiceIODelegate {
@ -271,7 +279,7 @@ extension VMSessionState: CSUSBManagerDelegate {
nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceAttached device: CSUSBDevice) {
Task { @MainActor in
if vmState == .vmStarted {
if vmState == .started {
mostRecentConnectedDevice = device
}
allUsbDevices.append(device)
@ -369,7 +377,7 @@ extension VMSessionState: CSUSBManagerDelegate {
#endif
extension VMSessionState {
func start() {
func start(options: UTMVirtualMachineStartOptions = []) {
let audioSession = AVAudioSession.sharedInstance()
do {
let preferDeviceMicrophone = UserDefaults.standard.bool(forKey: "PreferDeviceMicrophone")
@ -384,7 +392,7 @@ extension VMSessionState {
}
Self.currentSession = self
NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self])
vm.requestVmStart()
vm.requestVmStart(options: options)
}
@objc private func suspend() {
@ -413,19 +421,18 @@ extension VMSessionState {
}
func powerDown() {
vm.requestVmDeleteState()
vm.vmStop { _ in
Task { @MainActor in
Task {
try? await vm.deleteSnapshot(name: nil)
try await vm.stop(usingMethod: .force)
self.stop()
}
}
}
func pauseResume() {
let shouldSaveState = !vm.isRunningAsSnapshot
if vm.state == .vmStarted {
let shouldSaveState = !vm.isRunningAsDisposible
if vm.state == .started {
vm.requestVmPause(save: shouldSaveState)
} else if vm.state == .vmPaused {
} else if vm.state == .paused {
vm.requestVmResume()
}
}
@ -439,8 +446,9 @@ extension VMSessionState {
if shouldAutosave {
logger.info("Saving VM state on low memory warning.")
vm.vmSaveState { _ in
Task {
// ignore error
try? await vm.saveSnapshot(name: nil)
}
}
}
@ -448,7 +456,7 @@ extension VMSessionState {
func didEnterBackground() {
logger.info("Entering background")
let shouldAutosaveBackground = UserDefaults.standard.bool(forKey: "AutosaveBackground")
if shouldAutosaveBackground && vmState == .vmStarted {
if shouldAutosaveBackground && vmState == .started {
logger.info("Saving snapshot")
var task: UIBackgroundTaskIdentifier = .invalid
task = UIApplication.shared.beginBackgroundTask {
@ -456,12 +464,13 @@ extension VMSessionState {
UIApplication.shared.endBackgroundTask(task)
task = .invalid
}
vm.vmSaveState { error in
if let error = error {
logger.error("error saving snapshot: \(error)")
} else {
Task {
do {
try await vm.saveSnapshot()
self.hasAutosave = true
logger.info("Save snapshot complete")
} catch {
logger.error("error saving snapshot: \(error)")
}
UIApplication.shared.endBackgroundTask(task)
task = .invalid
@ -471,7 +480,7 @@ extension VMSessionState {
func didEnterForeground() {
logger.info("Entering foreground!")
if (hasAutosave && vmState == .vmStarted) {
if (hasAutosave && vmState == .started) {
logger.info("Deleting snapshot")
vm.requestVmDeleteState()
}

View File

@ -242,6 +242,6 @@ struct VMSettingsView_Previews: PreviewProvider {
@State static private var config = UTMQemuConfiguration()
static var previews: some View {
VMSettingsView(vm: VMData(wrapping: UTMVirtualMachine()), config: config)
VMSettingsView(vm: VMData(from: .empty), config: config)
}
}

View File

@ -82,7 +82,7 @@ struct VMToolbarView: View {
GeometryReader { geometry in
Group {
Button {
if session.vm.state == .vmStarted {
if session.vm.state == .started {
state.alert = .powerDown
} else {
state.alert = .terminateApp

View File

@ -57,7 +57,7 @@ struct VMWindowView: View {
Spacer()
if state.isBusy {
Spinner(size: .large)
} else if session.vmState == .vmPaused {
} else if session.vmState == .paused {
Button {
session.vm.requestVmResume()
} label: {
@ -200,33 +200,31 @@ struct VMWindowView: View {
}
}
private func vmStateUpdated(from oldState: UTMVMState?, to vmState: UTMVMState) {
if oldState == .vmStarted {
private func vmStateUpdated(from oldState: UTMVirtualMachineState?, to vmState: UTMVirtualMachineState) {
if oldState == .started {
saveWindow()
}
switch vmState {
case .vmStopped, .vmPaused:
case .stopped, .paused:
withOptionalAnimation {
state.isBusy = false
state.isRunning = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
if session.vmState == .vmStopped && session.fatalError == nil {
if session.vmState == .stopped && session.fatalError == nil {
session.stop()
}
}
case .vmPausing, .vmStopping, .vmStarting, .vmResuming:
case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
withOptionalAnimation {
state.isBusy = true
state.isRunning = false
}
case .vmStarted:
case .started:
withOptionalAnimation {
state.isBusy = false
state.isRunning = true
}
@unknown default:
break
}
}

View File

@ -95,11 +95,10 @@ fileprivate struct WizardWrapper: View {
let config = try await wizardState.generateConfig()
if let qemuConfig = config.qemuConfig {
let vm = try await data.create(config: qemuConfig)
let wrapped = await vm.wrapped as! UTMQemuVirtualMachine
if #available(iOS 15, *) {
// This is broken on iOS 14
await MainActor.run {
wrapped.isGuestToolsInstallRequested = wizardState.isGuestToolsInstallRequested
qemuConfig.qemu.isGuestToolsInstallRequested = wizardState.isGuestToolsInstallRequested
}
}
} else {

View File

@ -25,7 +25,7 @@
guard let vmList = data?.vmWindows.keys else {
return false
}
return vmList.contains(where: { $0.state == .vmStarted || ($0.state == .vmPaused && !$0.hasSaveState) })
return vmList.contains(where: { $0.wrapped?.state == .started || ($0.wrapped?.state == .paused && !$0.hasSuspendState) })
}
@MainActor
@ -90,7 +90,7 @@
}
}
return .terminateLater
} else if vmList.allSatisfy({ $0.state == .vmStopped || $0.state == .vmPaused }) { // All VMs are stopped or suspended
} else if vmList.allSatisfy({ !$0.isLoaded || $0.wrapped?.state == .stopped || $0.wrapped?.state == .paused }) { // All VMs are stopped or suspended
return .terminateNow
} else { // There could be some VMs in other states (starting, pausing, etc.)
return .terminateCancel

View File

@ -26,7 +26,7 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
}
var appleConfig: UTMAppleConfiguration! {
vm?.config.appleConfig
appleVM?.config
}
var defaultTitle: String {
@ -85,24 +85,21 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
startPauseToolbarItem.isEnabled = true
resizeConsoleToolbarItem.isEnabled = false
if #available(macOS 12, *) {
isPowerForce = false
sharedFolderToolbarItem.isEnabled = appleConfig.system.boot.operatingSystem == .linux
} else {
// stop() not available on macOS 11 for some reason
restartToolbarItem.isEnabled = false
sharedFolderToolbarItem.isEnabled = false
isPowerForce = true
}
}
override func enterSuspended(isBusy busy: Bool) {
isPowerForce = true
super.enterSuspended(isBusy: busy)
}
override func virtualMachine(_ vm: UTMVirtualMachine, didTransitionTo state: UTMVMState) {
super.virtualMachine(vm, didTransitionTo: state)
if state == .vmStopped && isInstallSuccessful {
override func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
super.virtualMachine(vm, didTransitionToState: state)
if state == .stopped && isInstallSuccessful {
isInstallSuccessful = false
vm.requestVmStart()
}
@ -112,15 +109,6 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
// implement in subclass
}
override func stopButtonPressed(_ sender: Any) {
if isPowerForce {
super.stopButtonPressed(sender)
} else {
appleVM.requestVmStop(force: false)
isPowerForce = true
}
}
override func resizeConsoleButtonPressed(_ sender: Any) {
// implement in subclass
}
@ -144,6 +132,29 @@ class VMDisplayAppleWindowController: VMDisplayWindowController {
openShareMenu(sender)
}
}
// MARK: - Installation progress
override func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
Task { @MainActor in
self.window!.subtitle = ""
if success {
// delete IPSW setting
self.enterSuspended(isBusy: true)
self.appleConfig.system.boot.macRecoveryIpswURL = nil
self.appleVM.registryEntry.macRecoveryIpsw = nil
self.isInstallSuccessful = true
}
}
}
override func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
Task { @MainActor in
let installationFormat = NSLocalizedString("Installation: %@", comment: "VMDisplayAppleWindowController")
let percentString = NumberFormatter.localizedString(from: progress as NSNumber, number: .percent)
self.window!.subtitle = String.localizedStringWithFormat(installationFormat, percentString)
}
}
}
@available(macOS 12, *)
@ -246,33 +257,10 @@ extension VMDisplayAppleWindowController {
}
}
extension VMDisplayAppleWindowController {
func virtualMachine(_ vm: UTMVirtualMachine, didCompleteInstallation success: Bool) {
DispatchQueue.main.async {
self.window!.subtitle = ""
if success {
// delete IPSW setting
self.enterSuspended(isBusy: true)
self.appleConfig.system.boot.macRecoveryIpswURL = nil
self.appleVM.registryEntry.macRecoveryIpsw = nil
self.isInstallSuccessful = true
}
}
}
func virtualMachine(_ vm: UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
DispatchQueue.main.async {
let installationFormat = NSLocalizedString("Installation: %@", comment: "VMDisplayAppleWindowController")
let percentString = NumberFormatter.localizedString(from: progress as NSNumber, number: .percent)
self.window!.subtitle = String.localizedStringWithFormat(installationFormat, percentString)
}
}
}
extension VMDisplayAppleWindowController: UTMScreenshotProvider {
var screenshot: CSScreenshot? {
var screenshot: PlatformImage? {
if let image = mainView?.image() {
return CSScreenshot(image: image)
return image
} else {
return nil
}

View File

@ -26,7 +26,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
}
var vmQemuConfig: UTMQemuConfiguration! {
vm?.config.qemuConfig
qemuVM?.config
}
var defaultTitle: String {
@ -34,7 +34,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
}
var defaultSubtitle: String {
if qemuVM.isRunningAsSnapshot {
if qemuVM.isRunningAsDisposible {
return NSLocalizedString("Disposable Mode", comment: "VMDisplayQemuDisplayController")
} else {
return ""
@ -68,7 +68,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
}
override var shouldSaveOnPause: Bool {
!qemuVM.isRunningAsSnapshot
!qemuVM.isRunningAsDisposible
}
override func enterLive() {
@ -92,7 +92,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
}
override func enterSuspended(isBusy busy: Bool) {
if vm.state == .vmStopped {
if vm.state == .stopped {
connectedUsbDevices.removeAll()
allUsbDevices.removeAll()
if isSecondary {
@ -130,7 +130,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
} else {
let item = NSMenuItem()
item.title = NSLocalizedString("Install Windows Guest Tools…", comment: "VMDisplayWindowController")
item.isEnabled = !qemuVM.isGuestToolsInstallRequested
item.isEnabled = !vmQemuConfig.qemu.isGuestToolsInstallRequested
item.target = self
item.action = #selector(installWindowsGuestTools)
menu.addItem(item)
@ -227,7 +227,7 @@ class VMDisplayQemuWindowController: VMDisplayWindowController {
}
@MainActor private func installWindowsGuestTools(sender: AnyObject) {
qemuVM.isGuestToolsInstallRequested = true
vmQemuConfig.qemu.isGuestToolsInstallRequested = true
}
}
@ -353,8 +353,8 @@ extension VMDisplayQemuWindowController: CSUSBManagerDelegate {
func spiceUsbManager(_ usbManager: CSUSBManager, deviceAttached device: CSUSBDevice) {
logger.debug("USB device attached: \(device)")
if !isNoUsbPrompt {
DispatchQueue.main.async {
if self.window!.isKeyWindow && self.vm.state == .vmStarted {
Task { @MainActor in
if self.window!.isKeyWindow && self.vm.state == .started {
self.showConnectPrompt(for: device)
}
}
@ -663,3 +663,12 @@ extension VMDisplayQemuWindowController {
return secondary
}
}
// MARK: - Computer wakeup
extension VMDisplayQemuWindowController {
@objc override func didWake(_ notification: NSNotification) {
Task {
try? await qemuVM.guestAgent?.guestSetTime(NSDate.now.timeIntervalSince1970)
}
}
}

View File

@ -142,10 +142,10 @@ class VMDisplayQemuMetalWindowController: VMDisplayQemuWindowController {
override func enterSuspended(isBusy busy: Bool) {
if !busy {
metalView.isHidden = true
screenshotView.image = vm.screenshot?.image
screenshotView.image = vm.screenshot
screenshotView.isHidden = false
}
if vm.state == .vmStopped {
if vm.state == .stopped {
vmDisplay = nil
vmInput = nil
}

View File

@ -57,7 +57,7 @@ class VMDisplayQemuTerminalWindowController: VMDisplayQemuWindowController, VMDi
}
override func enterSuspended(isBusy busy: Bool) {
if vm.state == .vmStopped {
if vm.state == .stopped {
vmSerialPort = nil
}
super.enterSuspended(isBusy: busy)

View File

@ -21,7 +21,7 @@ import SwiftUI
private let kVMDefaultResizeCmd = "stty cols $COLS rows $ROWS\\n"
protocol VMDisplayTerminal {
var vm: UTMVirtualMachine! { get }
var vm: (any UTMVirtualMachine)! { get }
var isOptionAsMetaKey: Bool { get }
@MainActor func setupTerminal(_ terminalView: TerminalView, using config: UTMConfigurationTerminal, id: Int, for window: NSWindow)
func resizeCommand(for terminal: TerminalView, using config: UTMConfigurationTerminal) -> String

View File

@ -16,7 +16,7 @@
import IOKit.pwr_mgt
class VMDisplayWindowController: NSWindowController {
class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
@IBOutlet weak var displayView: NSView!
@IBOutlet weak var screenshotView: NSImageView!
@ -36,10 +36,9 @@ class VMDisplayWindowController: NSWindowController {
@IBOutlet weak var resizeConsoleToolbarItem: NSToolbarItem!
@IBOutlet weak var windowsToolbarItem: NSToolbarItem!
var isPowerForce: Bool = false
var shouldAutoStartVM: Bool = true
var shouldSaveOnPause: Bool { true }
var vm: UTMVirtualMachine!
var vm: (any UTMVirtualMachine)!
var onClose: ((Notification) -> Void)?
private(set) var secondaryWindows: [VMDisplayWindowController] = []
private(set) weak var primaryWindow: VMDisplayWindowController?
@ -60,7 +59,7 @@ class VMDisplayWindowController: NSWindowController {
self
}
convenience init(vm: UTMVirtualMachine, onClose: ((Notification) -> Void)?) {
convenience init(vm: any UTMVirtualMachine, onClose: ((Notification) -> Void)?) {
self.init(window: nil)
self.vm = vm
self.onClose = onClose
@ -71,21 +70,25 @@ class VMDisplayWindowController: NSWindowController {
NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
}
@IBAction func stopButtonPressed(_ sender: Any) {
private func stop(isKill: Bool = false) {
showConfirmAlert(NSLocalizedString("This may corrupt the VM and any unsaved changes will be lost. To quit safely, shut down from the guest.", comment: "VMDisplayWindowController")) {
self.enterSuspended(isBusy: true) // early indicator
self.vm.requestVmDeleteState()
self.vm.requestVmStop(force: self.isPowerForce)
self.vm.requestVmStop(force: isKill)
}
}
@IBAction func stopButtonPressed(_ sender: Any) {
stop(isKill: false)
}
@IBAction func startPauseButtonPressed(_ sender: Any) {
enterSuspended(isBusy: true) // early indicator
if vm.state == .vmStarted {
if vm.state == .started {
vm.requestVmPause(save: shouldSaveOnPause)
} else if vm.state == .vmPaused {
} else if vm.state == .paused {
vm.requestVmResume()
} else if vm.state == .vmStopped {
} else if vm.state == .stopped {
vm.requestVmStart()
} else {
logger.error("Invalid state \(vm.state)")
@ -122,7 +125,7 @@ class VMDisplayWindowController: NSWindowController {
window!.recalculateKeyViewLoop()
setupStopButtonMenu()
if vm.state == .vmStopped {
if vm.state == .stopped {
enterSuspended(isBusy: false)
} else {
enterLive()
@ -131,14 +134,14 @@ class VMDisplayWindowController: NSWindowController {
super.windowDidLoad()
}
public func requestAutoStart() {
public func requestAutoStart(options: UTMVirtualMachineStartOptions = []) {
guard shouldAutoStartVM else {
return
}
DispatchQueue.global(qos: .userInitiated).async {
if (self.vm.state == .vmStopped) {
self.vm.requestVmStart()
} else if (self.vm.state == .vmPaused) {
if (self.vm.state == .stopped) {
self.vm.requestVmStart(options: options)
} else if (self.vm.state == .paused) {
self.vm.requestVmResume()
}
}
@ -171,7 +174,7 @@ class VMDisplayWindowController: NSWindowController {
func enterSuspended(isBusy busy: Bool) {
overlayView.isHidden = false
let playDescription = NSLocalizedString("Play", comment: "VMDisplayWindowController")
let stopped = vm.state == .vmStopped
let stopped = vm.state == .stopped
startPauseToolbarItem.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: playDescription)
startPauseToolbarItem.label = playDescription
if busy {
@ -233,7 +236,39 @@ class VMDisplayWindowController: NSWindowController {
secondaryWindow.primaryWindow = self
secondaryWindow.showWindow(self)
self.showWindow(self) // show primary window on top
secondaryWindow.virtualMachine(vm, didTransitionTo: vm.state) // show correct starting state
secondaryWindow.virtualMachine(vm, didTransitionToState: vm.state) // show correct starting state
}
// MARK: - Virtual machine delegate
func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
switch state {
case .stopped, .paused:
enterSuspended(isBusy: false)
case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
enterSuspended(isBusy: true)
case .started:
enterLive()
}
for subwindow in secondaryWindows {
subwindow.virtualMachine(vm, didTransitionToState: state)
}
}
func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
showErrorAlert(message) { _ in
if vm.state != .started && vm.state != .paused {
self.close()
}
}
}
func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
}
func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
}
}
@ -246,7 +281,7 @@ extension VMDisplayWindowController: NSWindowDelegate {
guard !isSecondary else {
return true
}
guard !(vm.state == .vmStopped || (vm.state == .vmPaused && vm.hasSaveState)) else {
guard !(vm.state == .stopped || (vm.state == .paused && vm.registryEntry.hasSaveState)) else {
return true
}
guard !isNoQuitConfirmation else {
@ -324,12 +359,14 @@ extension VMDisplayWindowController {
item2.target = self
item2.action = #selector(forceShutDown)
menu.addItem(item2)
if type(of: vm).capabilities.supportsProcessKill {
let item3 = NSMenuItem()
item3.title = NSLocalizedString("Force kill", comment: "VMDisplayWindowController")
item3.toolTip = NSLocalizedString("Force kill the VM process with high risk of data corruption.", comment: "VMDisplayWindowController")
item3.target = self
item3.action = #selector(forceKill)
menu.addItem(item3)
}
stopToolbarItem.menu = menu
}
@ -338,55 +375,17 @@ extension VMDisplayWindowController {
}
@MainActor @objc private func forceShutDown(sender: AnyObject) {
let prev = isPowerForce
isPowerForce = false
stopButtonPressed(sender)
isPowerForce = prev
stop()
}
@MainActor @objc private func forceKill(sender: AnyObject) {
let prev = isPowerForce
isPowerForce = true
stopButtonPressed(sender)
isPowerForce = prev
}
}
// MARK: - VM Delegate
extension VMDisplayWindowController: UTMVirtualMachineDelegate {
func virtualMachine(_ vm: UTMVirtualMachine, didTransitionTo state: UTMVMState) {
switch state {
case .vmStopped, .vmPaused:
enterSuspended(isBusy: false)
case .vmPausing, .vmStopping, .vmStarting, .vmResuming:
enterSuspended(isBusy: true)
case .vmStarted:
enterLive()
@unknown default:
break
}
for subwindow in secondaryWindows {
subwindow.virtualMachine(vm, didTransitionTo: state)
}
}
func virtualMachine(_ vm: UTMVirtualMachine, didErrorWithMessage message: String) {
showErrorAlert(message) { _ in
if vm.state != .vmStarted && vm.state != .vmPaused {
self.close()
}
}
stop(isKill: true)
}
}
// MARK: - Computer wakeup
extension VMDisplayWindowController {
@objc private func didWake(_ notification: NSNotification) {
if let qemuVM = vm as? UTMQemuVirtualMachine {
Task {
try? await qemuVM.guestAgent?.guestSetTime(NSDate.now.timeIntervalSince1970)
}
}
@objc func didWake(_ notification: NSNotification) {
// do something in subclass
}
}

View File

@ -19,33 +19,30 @@ import Carbon.HIToolbox
@available(macOS 11, *)
extension UTMData {
func run(vm: VMData, startImmediately: Bool = true) {
guard let vm = vm.wrapped else {
return
}
func run(vm: VMData, options: UTMVirtualMachineStartOptions = [], startImmediately: Bool = true) {
var window: Any? = vmWindows[vm]
if window == nil {
let close = { (notification: Notification) -> Void in
self.vmWindows.removeValue(forKey: vm)
window = nil
}
if let avm = vm as? UTMAppleVirtualMachine {
if avm.appleConfig.system.architecture == UTMAppleConfigurationSystem.currentArchitecture {
let primarySerialIndex = avm.appleConfig.serials.firstIndex { $0.mode == .builtin }
if let avm = vm.wrapped as? UTMAppleVirtualMachine {
if avm.config.system.architecture == UTMAppleConfigurationSystem.currentArchitecture {
let primarySerialIndex = avm.config.serials.firstIndex { $0.mode == .builtin }
if let primarySerialIndex = primarySerialIndex {
window = VMDisplayAppleTerminalWindowController(primaryForIndex: primarySerialIndex, vm: avm, onClose: close)
}
if #available(macOS 12, *), !avm.appleConfig.displays.isEmpty {
window = VMDisplayAppleDisplayWindowController(vm: vm, onClose: close)
} else if avm.appleConfig.displays.isEmpty && window == nil {
if #available(macOS 12, *), !avm.config.displays.isEmpty {
window = VMDisplayAppleDisplayWindowController(vm: avm, onClose: close)
} else if avm.config.displays.isEmpty && window == nil {
window = VMHeadlessSessionState(for: avm, onStop: close)
}
}
}
if let qvm = vm as? UTMQemuVirtualMachine {
if qvm.config.qemuHasDisplay {
if let qvm = vm.wrapped as? UTMQemuVirtualMachine {
if !qvm.config.displays.isEmpty {
window = VMDisplayQemuMetalWindowController(vm: qvm, onClose: close)
} else if qvm.config.qemuHasTerminal {
} else if !qvm.config.serials.filter({ $0.mode == .builtin }).isEmpty {
window = VMDisplayQemuTerminalWindowController(vm: qvm, onClose: close)
} else {
window = VMHeadlessSessionState(for: qvm, onStop: close)
@ -59,19 +56,19 @@ extension UTMData {
}
if let unwrappedWindow = window as? VMDisplayWindowController {
vmWindows[vm] = unwrappedWindow
vm.delegate = unwrappedWindow
vm.wrapped!.delegate = unwrappedWindow
unwrappedWindow.showWindow(nil)
unwrappedWindow.window!.makeMain()
if startImmediately {
unwrappedWindow.requestAutoStart()
unwrappedWindow.requestAutoStart(options: options)
}
} else if let unwrappedWindow = window as? VMHeadlessSessionState {
vmWindows[vm] = unwrappedWindow
if startImmediately {
if vm.state == .vmPaused {
vm.requestVmResume()
if vm.wrapped!.state == .paused {
vm.wrapped!.requestVmResume()
} else {
vm.requestVmStart()
vm.wrapped!.requestVmStart(options: options)
}
}
} else {
@ -83,26 +80,26 @@ extension UTMData {
guard let wrapped = vm.wrapped else {
return
}
if wrapped.hasSaveState {
wrapped.requestVmDeleteState()
Task {
if wrapped.registryEntry.isSuspended {
try? await wrapped.deleteSnapshot(name: nil)
}
wrapped.vmStop(force: false, completion: { _ in
try? await wrapped.stop(usingMethod: .force)
await MainActor.run {
self.close(vm: vm)
})
}
}
}
func close(vm: VMData) {
guard let wrapped = vm.wrapped else {
return
}
if let window = vmWindows.removeValue(forKey: wrapped) as? VMDisplayWindowController {
if let window = vmWindows.removeValue(forKey: vm) as? VMDisplayWindowController {
DispatchQueue.main.async {
window.close()
}
}
}
func trySendTextSpice(vm: UTMVirtualMachine, text: String) {
func trySendTextSpice(vm: VMData, text: String) {
guard text.count > 0 else { return }
if let vc = vmWindows[vm] as? VMDisplayQemuMetalWindowController {
KeyCodeMap.createKeyMapIfNeeded()
@ -197,7 +194,7 @@ extension UTMData {
}
}
func tryClickAtPoint(vm: UTMQemuVirtualMachine, point: CGPoint, button: CSInputButton) {
func tryClickAtPoint(vm: VMData, point: CGPoint, button: CSInputButton) {
if let vc = vmWindows[vm] as? VMDisplayQemuMetalWindowController {
vc.mouseMove(absolutePoint: point, button: [])
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {

View File

@ -66,7 +66,7 @@ private struct VMMenuItem: View {
data.stop(vm: vm)
}
Button("Suspend") {
let isSnapshot = (vm.wrapped as? UTMQemuVirtualMachine)?.isRunningAsSnapshot ?? false
let isSnapshot = (vm.wrapped as? UTMQemuVirtualMachine)?.isRunningAsDisposible ?? false
vm.wrapped!.requestVmPause(save: !isSnapshot)
}
Button("Reset") {

View File

@ -22,7 +22,7 @@ struct VMAppleRemovableDrivesView: View {
case diskImage
}
@ObservedObject var vm: UTMAppleVirtualMachine
@ObservedObject var vm: VMData
@ObservedObject var config: UTMAppleConfiguration
@ObservedObject var registryEntry: UTMRegistryEntry
@EnvironmentObject private var data: UTMData
@ -33,6 +33,10 @@ struct VMAppleRemovableDrivesView: View {
/// Explanation see "SwiftUI FileImporter modal bug" in `showFileImporter`
@State private var workaroundFileImporterBug: Bool = false
private var appleVM: UTMAppleVirtualMachine! {
vm.wrapped as? UTMAppleVirtualMachine
}
private var hasSharingFeatures: Bool {
if #available(macOS 13, *) {
return true
@ -91,7 +95,7 @@ struct VMAppleRemovableDrivesView: View {
}
} label: {
Label("External Drive", systemImage: "externaldrive")
}.disabled(vm.hasSaveState || vm.state != .vmStopped)
}.disabled(vm.hasSuspendState || vm.state != .stopped)
} else {
Label("\(diskImage.sizeString) Drive", systemImage: "internaldrive")
}
@ -186,7 +190,7 @@ struct VMAppleRemovableDrivesView: View {
}
private func deleteShareDirectory(_ sharedDirectory: UTMRegistryEntry.File) {
vm.registryEntry.sharedDirectories.removeAll { existing in
appleVM.registryEntry.sharedDirectories.removeAll { existing in
existing.url == sharedDirectory.url
}
}
@ -205,10 +209,10 @@ struct VMAppleRemovableDrivesView: View {
}
struct VMAppleRemovableDrivesView_Previews: PreviewProvider {
@StateObject static var vm = UTMAppleVirtualMachine()
@StateObject static var vm = VMData(from: .empty)
@StateObject static var config = UTMAppleConfiguration()
static var previews: some View {
VMAppleRemovableDrivesView(vm: vm, config: config, registryEntry: vm.registryEntry)
VMAppleRemovableDrivesView(vm: vm, config: config, registryEntry: vm.registryEntry!)
}
}

View File

@ -19,10 +19,10 @@ import IOKit.pwr_mgt
/// Represents the UI state for a single headless VM session.
@MainActor class VMHeadlessSessionState: NSObject, ObservableObject {
let vm: UTMVirtualMachine
let vm: any UTMVirtualMachine
var onStop: ((Notification) -> Void)?
@Published var vmState: UTMVMState = .vmStopped
@Published var vmState: UTMVirtualMachineState = .stopped
@Published var fatalError: String?
@ -31,7 +31,7 @@ import IOKit.pwr_mgt
@Setting("PreventIdleSleep") private var isPreventIdleSleep: Bool = false
init(for vm: UTMVirtualMachine, onStop: ((Notification) -> Void)?) {
init(for vm: any UTMVirtualMachine, onStop: ((Notification) -> Void)?) {
self.vm = vm
self.onStop = onStop
super.init()
@ -45,14 +45,14 @@ import IOKit.pwr_mgt
}
extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
nonisolated func virtualMachine(_ vm: UTMVirtualMachine, didTransitionTo state: UTMVMState) {
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
Task { @MainActor in
vmState = state
if state == .vmStarted {
if state == .started {
hasStarted = true
didStart()
}
if state == .vmStopped {
if state == .stopped {
if hasStarted {
didStop() // graceful exit
}
@ -61,7 +61,7 @@ extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
}
}
nonisolated func virtualMachine(_ vm: UTMVirtualMachine, didErrorWithMessage message: String) {
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
Task { @MainActor in
fatalError = message
NotificationCenter.default.post(name: .vmSessionError, object: nil, userInfo: ["Session": self, "Message": message])
@ -71,6 +71,14 @@ extension VMHeadlessSessionState: UTMVirtualMachineDelegate {
}
}
}
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
}
nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
}
}
extension VMHeadlessSessionState {

View File

@ -98,9 +98,8 @@ struct VMWizardView: View {
#endif
if let qemuConfig = config.qemuConfig {
let vm = try await data.create(config: qemuConfig)
let wrapped = await vm.wrapped as! UTMQemuVirtualMachine
await MainActor.run {
wrapped.isGuestToolsInstallRequested = wizardState.isGuestToolsInstallRequested
qemuConfig.qemu.isGuestToolsInstallRequested = wizardState.isGuestToolsInstallRequested
}
} else if let appleConfig = config.appleConfig {
_ = try await data.create(config: appleConfig)

View File

@ -18,7 +18,7 @@ import Foundation
@objc extension UTMScriptingVirtualMachineImpl {
@objc var configuration: [AnyHashable : Any] {
let wrapper = UTMScriptingConfigImpl(vm.config.wrappedValue as! any UTMConfiguration, data: data)
let wrapper = UTMScriptingConfigImpl(vm.config, data: data)
return wrapper.serializeConfiguration()
}
@ -28,10 +28,10 @@ import Foundation
guard let newConfiguration = newConfiguration else {
throw ScriptingError.invalidParameter
}
guard vm.state == .vmStopped else {
guard vm.state == .stopped else {
throw ScriptingError.notStopped
}
let wrapper = UTMScriptingConfigImpl(vm.config.wrappedValue as! any UTMConfiguration)
let wrapper = UTMScriptingConfigImpl(vm.config)
try wrapper.updateConfiguration(from: newConfiguration)
try await data.save(vm: box)
}

View File

@ -22,7 +22,7 @@ import QEMUKitInternal
class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
@nonobjc var box: VMData
@nonobjc var data: UTMData
@nonobjc var vm: UTMVirtualMachine! {
@nonobjc var vm: (any UTMVirtualMachine)! {
box.wrapped
}
@ -31,23 +31,23 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
}
@objc var name: String {
vm.detailsTitleLabel
box.detailsTitleLabel
}
@objc var notes: String {
vm.detailsNotes ?? ""
box.detailsNotes ?? ""
}
@objc var machine: String {
vm.detailsSystemTargetLabel
box.detailsSystemTargetLabel
}
@objc var architecture: String {
vm.detailsSystemArchitectureLabel
box.detailsSystemArchitectureLabel
}
@objc var memory: String {
vm.detailsSystemMemoryLabel
box.detailsSystemMemoryLabel
}
@objc var backend: UTMScriptingBackend {
@ -62,21 +62,22 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
@objc var status: UTMScriptingStatus {
switch vm.state {
case .vmStopped: return .stopped
case .vmStarting: return .starting
case .vmStarted: return .started
case .vmPausing: return .pausing
case .vmPaused: return .paused
case .vmResuming: return .resuming
case .vmStopping: return .stopping
@unknown default: return .stopped
case .stopped: return .stopped
case .starting: return .starting
case .started: return .started
case .pausing: return .pausing
case .paused: return .paused
case .resuming: return .resuming
case .stopping: return .stopping
case .saving: return .pausing // FIXME: new entries
case .restoring: return .resuming // FIXME: new entries
}
}
@objc var serialPorts: [UTMScriptingSerialPortImpl] {
if let config = vm.config.qemuConfig {
if let config = vm.config as? UTMQemuConfiguration {
return config.serials.indices.map({ UTMScriptingSerialPortImpl(qemuSerial: config.serials[$0], parent: self, index: $0) })
} else if let config = vm.config.appleConfig {
} else if let config = vm.config as? UTMAppleConfiguration {
return config.serials.indices.map({ UTMScriptingSerialPortImpl(appleSerial: config.serials[$0], parent: self, index: $0) })
} else {
return []
@ -106,16 +107,15 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
let shouldSaveState = command.evaluatedArguments?["saveFlag"] as? Bool ?? true
withScriptCommand(command) { [self] in
if !shouldSaveState {
guard let vm = vm as? UTMQemuVirtualMachine else {
guard type(of: vm).capabilities.supportsDisposibleMode else {
throw ScriptingError.operationNotSupported
}
vm.isRunningAsSnapshot = true
}
data.run(vm: box, startImmediately: false)
if vm.state == .vmStopped {
try await vm.vmStart()
} else if vm.state == .vmPaused {
try await vm.vmResume()
if vm.state == .stopped {
try await vm.start(options: shouldSaveState ? [] : .bootDisposibleMode)
} else if vm.state == .paused {
try await vm.resume()
} else {
throw ScriptingError.operationNotAvailable
}
@ -125,10 +125,13 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
@objc func suspend(_ command: NSScriptCommand) {
let shouldSaveState = command.evaluatedArguments?["saveFlag"] as? Bool ?? false
withScriptCommand(command) { [self] in
guard vm.state == .vmStarted else {
guard vm.state == .started else {
throw ScriptingError.notRunning
}
try await vm.vmPause(save: shouldSaveState)
try await vm.pause()
if shouldSaveState {
try await vm.saveSnapshot(name: nil)
}
}
}
@ -140,23 +143,23 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
stopMethod = .force
}
withScriptCommand(command) { [self] in
guard vm.state == .vmStarted || stopMethod == .kill else {
guard vm.state == .started || stopMethod == .kill else {
throw ScriptingError.notRunning
}
switch stopMethod {
case .force:
try await vm.vmStop(force: false)
try await vm.stop(usingMethod: .force)
case .kill:
try await vm.vmStop(force: true)
try await vm.stop(usingMethod: .kill)
case .request:
vm.requestGuestPowerDown()
try await vm.stop(usingMethod: .request)
}
}
}
@objc func delete(_ command: NSDeleteCommand) {
withScriptCommand(command) { [self] in
guard vm.state == .vmStopped else {
guard vm.state == .stopped else {
throw ScriptingError.notStopped
}
try await data.delete(vm: box, alsoRegistry: true)
@ -166,7 +169,7 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
@objc func clone(_ command: NSCloneCommand) {
let properties = command.evaluatedArguments?["WithProperties"] as? [AnyHashable : Any]
withScriptCommand(command) { [self] in
guard vm.state == .vmStopped else {
guard vm.state == .stopped else {
throw ScriptingError.notStopped
}
let newVM = try await data.clone(vm: box)
@ -182,7 +185,7 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
// MARK: - Guest agent suite
@objc extension UTMScriptingVirtualMachineImpl {
@nonobjc private func withGuestAgent<Result>(_ block: (QEMUGuestAgent) async throws -> Result) async throws -> Result {
guard vm.state == .vmStarted else {
guard vm.state == .started else {
throw ScriptingError.notRunning
}
guard let vm = vm as? UTMQemuVirtualMachine else {

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 52;
objects = {
/* Begin PBXBuildFile section */
@ -197,9 +197,6 @@
848F71EC277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848F71EB277A2F47006A0240 /* UTMSerialPortDelegate.swift */; };
848F71ED277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848F71EB277A2F47006A0240 /* UTMSerialPortDelegate.swift */; };
848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848F71EB277A2F47006A0240 /* UTMSerialPortDelegate.swift */; };
84909A8927CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8827CABA54005605F1 /* UTMWrappedVirtualMachine.swift */; };
84909A8A27CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8827CABA54005605F1 /* UTMWrappedVirtualMachine.swift */; };
84909A8B27CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8827CABA54005605F1 /* UTMWrappedVirtualMachine.swift */; };
84909A8D27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8C27CACD5C005605F1 /* UTMPlaceholderVMView.swift */; };
84909A8E27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8C27CACD5C005605F1 /* UTMPlaceholderVMView.swift */; };
84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84909A8C27CACD5C005605F1 /* UTMPlaceholderVMView.swift */; };
@ -329,7 +326,6 @@
CE0B6CFB24AD568400FE012D /* UTMLegacyQemuConfiguration+Networking.m in Sources */ = {isa = PBXBuildFile; fileRef = CEA02A982436C7A30087E45F /* UTMLegacyQemuConfiguration+Networking.m */; };
CE0B6CFC24AD568400FE012D /* UTMLegacyQemuConfigurationPortForward.m in Sources */ = {isa = PBXBuildFile; fileRef = CE54252D2436E48D00E520F7 /* UTMLegacyQemuConfigurationPortForward.m */; };
CE0B6CFE24AD56AE00FE012D /* UTMLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = CE6EDCE1241DA0E900A719DC /* UTMLogging.m */; };
CE0B6D0024AD56AE00FE012D /* UTMVirtualMachine.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5F165B2261395000F3D56B /* UTMVirtualMachine.m */; };
CE0B6D0224AD56AE00FE012D /* UTMQemu.m in Sources */ = {isa = PBXBuildFile; fileRef = CE9D197B226542FE00355E14 /* UTMQemu.m */; };
CE0B6D0424AD56AE00FE012D /* UTMSpiceIO.m in Sources */ = {isa = PBXBuildFile; fileRef = E2D64BC8241DB24B0034E0C6 /* UTMSpiceIO.m */; };
CE0B6EBB24AD677200FE012D /* libgstgio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CE9D19622265425A00355E14 /* libgstgio.a */; };
@ -440,7 +436,6 @@
CE2D92AA24AD46670059923A /* UTMSpiceIO.m in Sources */ = {isa = PBXBuildFile; fileRef = E2D64BC8241DB24B0034E0C6 /* UTMSpiceIO.m */; };
CE2D92BC24AD46670059923A /* UTMLegacyQemuConfiguration+Drives.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5425302437C09C00E520F7 /* UTMLegacyQemuConfiguration+Drives.m */; };
CE2D92CB24AD46670059923A /* VMDisplayMetalViewController+Gamepad.m in Sources */ = {isa = PBXBuildFile; fileRef = 5286EC8F2437488E007E6CBC /* VMDisplayMetalViewController+Gamepad.m */; };
CE2D92D224AD46670059923A /* UTMVirtualMachine.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5F165B2261395000F3D56B /* UTMVirtualMachine.m */; };
CE2D92D724AD46670059923A /* UTMLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = CE6EDCE1241DA0E900A719DC /* UTMLogging.m */; };
CE2D92DA24AD46670059923A /* VMCursor.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3ADD692411C661002D6A5F /* VMCursor.m */; };
CE2D92E624AD46670059923A /* UTMLegacyQemuConfiguration+Networking.m in Sources */ = {isa = PBXBuildFile; fileRef = CEA02A982436C7A30087E45F /* UTMLegacyQemuConfiguration+Networking.m */; };
@ -675,7 +670,6 @@
CEA45EA3263519B5002FA97D /* VMConfigSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954724AD4F980059923A /* VMConfigSharingView.swift */; };
CEA45EA5263519B5002FA97D /* VMConfigInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954824AD4F980059923A /* VMConfigInputView.swift */; };
CEA45EA8263519B5002FA97D /* VMDisplayMetalViewController+Gamepad.m in Sources */ = {isa = PBXBuildFile; fileRef = 5286EC8F2437488E007E6CBC /* VMDisplayMetalViewController+Gamepad.m */; };
CEA45EAE263519B5002FA97D /* UTMVirtualMachine.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5F165B2261395000F3D56B /* UTMVirtualMachine.m */; };
CEA45EB3263519B5002FA97D /* UTMLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = CE6EDCE1241DA0E900A719DC /* UTMLogging.m */; };
CEA45EB7263519B5002FA97D /* VMToolbarModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D953824AD4F980059923A /* VMToolbarModifier.swift */; };
CEA45EB9263519B5002FA97D /* VMCursor.m in Sources */ = {isa = PBXBuildFile; fileRef = CE3ADD692411C661002D6A5F /* VMCursor.m */; };
@ -2888,7 +2882,6 @@
842B9F8D28CC58B700031EE7 /* UTMPatches.swift in Sources */,
CE19392626DCB094005CEC17 /* RAMSlider.swift in Sources */,
CE611BEB29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */,
84909A8927CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */,
CEE7E936287CFDB100282049 /* UTMLegacyQemuConfiguration+Constants.m in Sources */,
4B224B9D279D4D8100B63CFF /* InListButtonStyle.swift in Sources */,
2C33B3A92566C9B100A954A6 /* VMContextMenuModifier.swift in Sources */,
@ -2905,7 +2898,6 @@
84C60FB72681A41B00B58C00 /* VMToolbarView.swift in Sources */,
CE2D92CB24AD46670059923A /* VMDisplayMetalViewController+Gamepad.m in Sources */,
CEF0307426A2B40B00667B63 /* VMWizardHardwareView.swift in Sources */,
CE2D92D224AD46670059923A /* UTMVirtualMachine.m in Sources */,
841E997528AA1191003C6CB6 /* UTMRegistry.swift in Sources */,
8401868F288A50B90050AC51 /* VMDisplayViewControllerDelegate.swift in Sources */,
CE2D92D724AD46670059923A /* UTMLogging.m in Sources */,
@ -2981,7 +2973,6 @@
files = (
CEB63A7724F4654400CAF323 /* Main.swift in Sources */,
84E3A91B2946D2590024A740 /* UTMMenuBarExtraScene.swift in Sources */,
CE0B6D0024AD56AE00FE012D /* UTMVirtualMachine.m in Sources */,
CEB63A7B24F469E300CAF323 /* UTMJailbreak.m in Sources */,
83A004BB26A8CC95001AC09E /* UTMDownloadTask.swift in Sources */,
848A98B0286A0F74006F0550 /* UTMAppleConfiguration.swift in Sources */,
@ -3057,7 +3048,6 @@
8401FDA8269D4A4100265F0D /* VMConfigAppleSharingView.swift in Sources */,
CE0B6CFC24AD568400FE012D /* UTMLegacyQemuConfigurationPortForward.m in Sources */,
CEF0306C26A2AFDF00667B63 /* VMWizardStartView.swift in Sources */,
84909A8B27CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */,
843BF83628450C0B0029D60D /* UTMQemuConfigurationSound.swift in Sources */,
848A98C6286F332D006F0550 /* UTMConfiguration.swift in Sources */,
CE8813D624CD265700532628 /* VMShareFileModifier.swift in Sources */,
@ -3244,7 +3234,6 @@
CEA45EA5263519B5002FA97D /* VMConfigInputView.swift in Sources */,
841E58CF28937FED00137A20 /* UTMMainView.swift in Sources */,
CEA45EA8263519B5002FA97D /* VMDisplayMetalViewController+Gamepad.m in Sources */,
CEA45EAE263519B5002FA97D /* UTMVirtualMachine.m in Sources */,
848D99C12866D9CE0055C215 /* QEMUArgumentBuilder.swift in Sources */,
CEA45EB3263519B5002FA97D /* UTMLogging.m in Sources */,
848D99B928630A780055C215 /* VMConfigSerialView.swift in Sources */,
@ -3273,7 +3262,6 @@
CE19392726DCB094005CEC17 /* RAMSlider.swift in Sources */,
84E6F6FE289319AE00080EEF /* VMToolbarDisplayMenuView.swift in Sources */,
841E58CC28937EE200137A20 /* UTMExternalSceneDelegate.swift in Sources */,
84909A8A27CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */,
CEF0307226A2B04400667B63 /* VMWizardView.swift in Sources */,
83034C0826AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
CEA45ED8263519B5002FA97D /* VMKeyboardButton.m in Sources */,