Ver Fonte

data: refactor UTMVirtualMachine to VMData for use in Views

This replaces the functionality of UTMWrappedVirtualMachine and moves some of
the presentation logic from UTMVirtualMachine.
osy há 2 anos atrás
pai
commit
d498239b38

+ 3 - 1
Managers/UTMQemuVirtualMachine.swift

@@ -416,7 +416,9 @@ extension UTMQemuVirtualMachine {
     @MainActor
     override func updateScreenshot() {
         ioService?.screenshot(completion: { screenshot in
-            self.screenshot = screenshot
+            Task { @MainActor in
+                self.screenshot = screenshot
+            }
         })
     }
     

+ 16 - 0
Managers/UTMRegistry.swift

@@ -82,6 +82,22 @@ class UTMRegistry: NSObject {
         return newEntry
     }
     
+    /// Gets an existing registry entry or create a new entry for a legacy bookmark
+    /// - Parameters:
+    ///   - uuid: UUID
+    ///   - name: VM name
+    ///   - path: VM path string
+    ///   - bookmark: VM bookmark
+    /// - Returns: Either an existing registry entry or a new entry
+    func entry(uuid: UUID, name: String, path: String, bookmark: Data? = nil) -> UTMRegistryEntry {
+        if let entry = entries[uuid.uuidString] {
+            return entry
+        }
+        let newEntry = UTMRegistryEntry(uuid: uuid, name: name, path: path, bookmark: bookmark)
+        entries[uuid.uuidString] = newEntry
+        return newEntry
+    }
+    
     /// Get an existing registry entry for a UUID
     /// - Parameter uuidString: UUID
     /// - Returns: An existing registry entry or nil if it does not exist

+ 14 - 8
Managers/UTMRegistryEntry.swift

@@ -50,17 +50,16 @@ import Foundation
         case macRecoveryIpsw = "MacRecoveryIpsw"
     }
     
-    init(newFrom vm: UTMVirtualMachine) {
+    init(uuid: UUID, name: String, path: String, bookmark: Data? = nil) {
+        _name = name
         let package: File?
-        let path = vm.path
-        if let wrappedVM = vm as? UTMWrappedVirtualMachine {
-            package = try? File(path: path.path, bookmark: wrappedVM.bookmark)
+        if let bookmark = bookmark {
+            package = try? File(path: path, bookmark: bookmark)
         } else {
-            package = try? File(url: path)
+            package = nil
         }
-        _name = vm.config.name
-        _package = package ?? File(path: path.path)
-        uuid = vm.config.uuid
+        _package = package ?? File(path: path)
+        self.uuid = uuid
         _isSuspended = false
         _externalDrives = [:]
         _sharedDirectories = []
@@ -69,6 +68,13 @@ 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) {
+            _package = package
+        }
+    }
+    
     required init(from decoder: Decoder) throws {
         let container = try decoder.container(keyedBy: CodingKeys.self)
         _name = try container.decode(String.self, forKey: .name)

+ 4 - 1
Managers/UTMVirtualMachine.swift

@@ -110,7 +110,7 @@ extension UTMVirtualMachine: ObservableObject {
     }
     
     /// Called when we have a duplicate UUID
-    @MainActor func changeUuid(to uuid: UUID, name: String? = nil) {
+    @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 {
@@ -125,6 +125,9 @@ extension UTMVirtualMachine: ObservableObject {
             fatalError("Invalid configuration.")
         }
         registryEntry = UTMRegistry.shared.entry(for: self)
+        if let existing = existing {
+            registryEntry.update(copying: existing)
+        }
     }
 }
 

+ 19 - 18
Platform/Shared/ContentView.swift

@@ -132,9 +132,9 @@ struct ContentView: View {
     }
     
     @MainActor private func handleUTMURL(with components: URLComponents) async throws {
-        func findVM() async -> UTMVirtualMachine? {
+        func findVM() -> VMData? {
             if let vmName = components.queryItems?.first(where: { $0.name == "name" })?.value {
-                return await data.virtualMachines.first(where: { $0.detailsTitleLabel == vmName })
+                return data.virtualMachines.first(where: { $0.detailsTitleLabel == vmName })
             } else {
                 return nil
             }
@@ -143,48 +143,48 @@ struct ContentView: View {
         if let action = components.host {
             switch action {
             case "start":
-                if let vm = await findVM(), vm.state == .vmStopped {
+                if let vm = findVM(), vm.wrapped?.state == .vmStopped {
                     data.run(vm: vm)
                 }
                 break
             case "stop":
-                if let vm = await findVM(), vm.state == .vmStarted {
-                    vm.requestVmStop(force: true)
+                if let vm = findVM(), vm.wrapped?.state == .vmStarted {
+                    vm.wrapped!.requestVmStop(force: true)
                     data.stop(vm: vm)
                 }
                 break
             case "restart":
-                if let vm = await findVM(), vm.state == .vmStarted {
-                    vm.requestVmReset()
+                if let vm = findVM(), vm.wrapped?.state == .vmStarted {
+                    vm.wrapped!.requestVmReset()
                 }
                 break
             case "pause":
-                if let vm = await findVM(), vm.state == .vmStarted {
+                if let vm = findVM(), vm.wrapped?.state == .vmStarted {
                     let shouldSaveOnPause: Bool
-                    if let vm = vm as? UTMQemuVirtualMachine {
+                    if let vm = vm.wrapped as? UTMQemuVirtualMachine {
                         shouldSaveOnPause = !vm.isRunningAsSnapshot
                     } else {
                         shouldSaveOnPause = true
                     }
-                    vm.requestVmPause(save: shouldSaveOnPause)
+                    vm.wrapped!.requestVmPause(save: shouldSaveOnPause)
                 }
             case "resume":
-                if let vm = await findVM(), vm.state == .vmPaused {
-                    vm.requestVmResume()
+                if let vm = findVM(), vm.wrapped?.state == .vmPaused {
+                    vm.wrapped!.requestVmResume()
                 }
                 break
             case "sendText":
-                if let vm = await findVM(), vm.state == .vmStarted {
-                    data.automationSendText(to: vm, urlComponents: components)
+                if let vm = findVM(), vm.wrapped?.state == .vmStarted {
+                    data.automationSendText(to: vm.wrapped!, urlComponents: components)
                 }
                 break
             case "click":
-                if let vm = await findVM(), vm.state == .vmStarted {
-                    data.automationSendMouse(to: vm, urlComponents: components)
+                if let vm = findVM(), vm.wrapped?.state == .vmStarted {
+                    data.automationSendMouse(to: vm.wrapped!, urlComponents: components)
                 }
                 break
             case "downloadVM":
-                data.downloadUTMZip(from: components)
+                await data.downloadUTMZip(from: components)
                 break
             default:
                 return
@@ -199,8 +199,9 @@ extension ContentView: DropDelegate {
     }
     
     func performDrop(info: DropInfo) -> Bool {
+        let urls = urlsFrom(info: info)
         data.busyWorkAsync {
-            for url in await urlsFrom(info: info) {
+            for url in urls {
                 
                 try await data.importUTM(from: url)
             }

+ 6 - 6
Platform/Shared/UTMUnavailableVMView.swift

@@ -17,20 +17,20 @@
 import SwiftUI
 
 struct UTMUnavailableVMView: View {
-    @ObservedObject var wrappedVM: UTMWrappedVirtualMachine
+    @ObservedObject var vm: VMData
     @EnvironmentObject private var data: UTMData
     
     var body: some View {
-        UTMPlaceholderVMView(title: wrappedVM.detailsTitleLabel,
-                             subtitle: wrappedVM.detailsSubtitleLabel,
+        UTMPlaceholderVMView(title: vm.detailsTitleLabel,
+                             subtitle: vm.detailsSubtitleLabel,
                              progress: nil,
                              imageOverlaySystemName: "questionmark.circle.fill",
-                             popover: { WrappedVMDetailsView(path: wrappedVM.path.path, onRemove: remove) },
+                             popover: { WrappedVMDetailsView(path: vm.pathUrl.path, onRemove: remove) },
                              onRemove: remove)
     }
     
     private func remove() {
-        data.listRemove(vm: wrappedVM)
+        data.listRemove(vm: vm)
     }
 }
 
@@ -73,6 +73,6 @@ fileprivate struct WrappedVMDetailsView: View {
 
 struct UTMUnavailableVMView_Previews: PreviewProvider {
     static var previews: some View {
-        UTMUnavailableVMView(wrappedVM: UTMWrappedVirtualMachine(bookmark: Data(), name: "Wrapped VM", path: URL(fileURLWithPath: "/")))
+        UTMUnavailableVMView(vm: VMData(wrapping: UTMWrappedVirtualMachine(bookmark: Data(), name: "Wrapped VM", path: URL(fileURLWithPath: "/"))))
     }
 }

+ 3 - 3
Platform/Shared/VMCardView.swift

@@ -17,7 +17,7 @@
 import SwiftUI
 
 struct VMCardView: View {
-    @ObservedObject var vm: UTMVirtualMachine
+    @ObservedObject var vm: VMData
     @EnvironmentObject private var data: UTMData
     
     #if os(macOS)
@@ -47,7 +47,7 @@ struct VMCardView: View {
             }.lineLimit(1)
             .truncationMode(.tail)
             Spacer()
-            if vm.state == .vmStopped {
+            if vm.isStopped {
                 Button {
                     data.run(vm: vm)
                 } label: {
@@ -113,6 +113,6 @@ struct Logo: View {
 
 struct VMCardView_Previews: PreviewProvider {
     static var previews: some View {
-        VMCardView(vm: UTMVirtualMachine(newConfig: UTMQemuConfiguration(), destinationURL: URL(fileURLWithPath: "/")))
+        VMCardView(vm: VMData(wrapping: UTMVirtualMachine(newConfig: UTMQemuConfiguration(), destinationURL: URL(fileURLWithPath: "/"))))
     }
 }

+ 1 - 1
Platform/Shared/VMConfirmActionModifier.swift

@@ -27,7 +27,7 @@ enum ConfirmAction: Int, Identifiable {
 }
 
 struct VMConfirmActionModifier: ViewModifier {
-    let vm: UTMVirtualMachine
+    let vm: VMData
     @Binding var confirmAction: ConfirmAction?
     let onConfirm: () -> Void
     @EnvironmentObject private var data: UTMData

+ 19 - 13
Platform/Shared/VMContextMenuModifier.swift

@@ -17,7 +17,7 @@
 import SwiftUI
 
 struct VMContextMenuModifier: ViewModifier {
-    @ObservedObject var vm: UTMVirtualMachine
+    @ObservedObject var vm: VMData
     @EnvironmentObject private var data: UTMData
     @State private var showSharePopup = false
     @State private var confirmAction: ConfirmAction?
@@ -27,7 +27,7 @@ struct VMContextMenuModifier: ViewModifier {
         content.contextMenu {
             #if os(macOS)
             Button {
-                NSWorkspace.shared.activateFileViewerSelecting([vm.path])
+                NSWorkspace.shared.activateFileViewerSelecting([vm.pathUrl])
             } label: {
                 Label("Show in Finder", systemImage: "folder")
             }.help("Reveal where the VM is stored.")
@@ -38,14 +38,20 @@ struct VMContextMenuModifier: ViewModifier {
                 data.edit(vm: vm)
             } label: {
                 Label("Edit", systemImage: "slider.horizontal.3")
-            }.disabled(vm.hasSaveState || vm.state != .vmStopped)
+            }.disabled(vm.hasSuspendState || !vm.isModifyAllowed)
             .help("Modify settings for this VM.")
-            if vm.hasSaveState || vm.state != .vmStopped {
+            if vm.hasSuspendState || !vm.isStopped {
                 Button {
                     confirmAction = .confirmStopVM
                 } label: {
                     Label("Stop", systemImage: "stop.fill")
                 }.help("Stop the running VM.")
+            } else if !vm.isModifyAllowed { // paused
+                Button {
+                    data.run(vm: vm)
+                } label: {
+                    Label("Resume", systemImage: "playpause.fill")
+                }.help("Resume running VM.")
             } else {
                 Divider()
                 
@@ -56,7 +62,7 @@ struct VMContextMenuModifier: ViewModifier {
                 }.help("Run the VM in the foreground.")
                 
                 #if os(macOS) && arch(arm64)
-                if #available(macOS 13, *), let appleConfig = vm.config.appleConfig, appleConfig.system.boot.operatingSystem == .macOS {
+                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)
@@ -66,9 +72,9 @@ struct VMContextMenuModifier: ViewModifier {
                 }
                 #endif
                 
-                if !vm.config.isAppleVirtualization {
+                if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
                     Button {
-                        vm.isRunningAsSnapshot = true
+                        qemuVM.isRunningAsSnapshot = true
                         data.run(vm: vm)
                     } label: {
                         Label("Run without saving changes", systemImage: "play")
@@ -76,7 +82,7 @@ struct VMContextMenuModifier: ViewModifier {
                 }
                 
                 #if os(iOS)
-                if let qemuVM = vm as? UTMQemuVirtualMachine {
+                if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
                     Button {
                         qemuVM.isGuestToolsInstallRequested = true
                     } label: {
@@ -100,7 +106,7 @@ struct VMContextMenuModifier: ViewModifier {
                     confirmAction = .confirmMoveVM
                 } label: {
                     Label("Move…", systemImage: "arrow.down.doc")
-                }.disabled(vm.state != .vmStopped)
+                }.disabled(!vm.isModifyAllowed)
                 .help("Move this VM from internal storage to elsewhere.")
             }
             #endif
@@ -122,14 +128,14 @@ struct VMContextMenuModifier: ViewModifier {
                     confirmAction = .confirmDeleteShortcut
                 } label: {
                     Label("Remove", systemImage: "trash")
-                }.disabled(vm.state != .vmStopped)
+                }.disabled(!vm.isModifyAllowed)
                 .help("Delete this shortcut. The underlying data will not be deleted.")
             } else {
                 DestructiveButton {
                     confirmAction = .confirmDeleteVM
                 } label: {
                     Label("Delete", systemImage: "trash")
-                }.disabled(vm.state != .vmStopped)
+                }.disabled(!vm.isModifyAllowed)
                 .help("Delete this VM and all its data.")
             }
         }
@@ -140,10 +146,10 @@ struct VMContextMenuModifier: ViewModifier {
                 showSharePopup.toggle()
             }
         })
-        .onChange(of: (vm as? UTMQemuVirtualMachine)?.isGuestToolsInstallRequested) { newValue in
+        .onChange(of: (vm.wrapped as? UTMQemuVirtualMachine)?.isGuestToolsInstallRequested) { newValue in
             if newValue == true {
                 data.busyWorkAsync {
-                    try await data.mountSupportTools(for: vm as! UTMQemuVirtualMachine)
+                    try await data.mountSupportTools(for: vm.wrapped as! UTMQemuVirtualMachine)
                 }
             }
         }

+ 21 - 21
Platform/Shared/VMDetailsView.swift

@@ -17,7 +17,7 @@
 import SwiftUI
 
 struct VMDetailsView: View {
-    @ObservedObject var vm: UTMVirtualMachine
+    @ObservedObject var vm: VMData
     @EnvironmentObject private var data: UTMData
     @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
     #if !os(macOS)
@@ -62,10 +62,10 @@ struct VMDetailsView: View {
                             .padding([.leading, .trailing])
                     }.padding([.leading, .trailing])
                     #if os(macOS)
-                    if let appleVM = vm as? UTMAppleVirtualMachine {
+                    if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
                         VMAppleRemovableDrivesView(vm: appleVM, config: appleVM.appleConfig, registryEntry: appleVM.registryEntry)
                             .padding([.leading, .trailing, .bottom])
-                    } else if let qemuVM = vm as? UTMQemuVirtualMachine {
+                    } else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
                         VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
                             .padding([.leading, .trailing, .bottom])
                     }
@@ -83,13 +83,13 @@ struct VMDetailsView: View {
                                 .fixedSize(horizontal: false, vertical: true)
                         }
                         #if os(macOS)
-                        if let appleVM = vm as? UTMAppleVirtualMachine {
+                        if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
                             VMAppleRemovableDrivesView(vm: appleVM, config: appleVM.appleConfig, registryEntry: appleVM.registryEntry)
-                        } else if let qemuVM = vm as? UTMQemuVirtualMachine {
+                        } else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
                             VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
                         }
                         #else
-                        let qemuVM = vm as! UTMQemuVirtualMachine
+                        let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
                         VMRemovableDrivesView(vm: qemuVM, config: qemuVM.qemuConfig)
                         #endif
                     }.padding([.leading, .trailing, .bottom])
@@ -98,12 +98,12 @@ struct VMDetailsView: View {
             .modifier(VMOptionalNavigationTitleModifier(vm: vm))
             .modifier(VMToolbarModifier(vm: vm, bottom: !regularScreenSizeClass))
             .sheet(isPresented: $data.showSettingsModal) {
-                if let qemuConfig = vm.config.qemuConfig {
+                if let qemuConfig = vm.config as? UTMQemuConfiguration {
                     VMSettingsView(vm: vm, config: qemuConfig)
                         .environmentObject(data)
                 }
                 #if os(macOS)
-                if let appleConfig = vm.config.appleConfig {
+                if let appleConfig = vm.config as? UTMAppleConfiguration {
                     VMSettingsView(vm: vm, config: appleConfig)
                         .environmentObject(data)
                 }
@@ -115,7 +115,7 @@ struct VMDetailsView: View {
 
 /// Returns just the content under macOS but adds the title on iOS. #3099
 private struct VMOptionalNavigationTitleModifier: ViewModifier {
-    @ObservedObject var vm: UTMVirtualMachine
+    @ObservedObject var vm: VMData
     
     func body(content: Content) -> some View {
         #if os(macOS)
@@ -127,7 +127,7 @@ private struct VMOptionalNavigationTitleModifier: ViewModifier {
 }
 
 struct Screenshot: View {
-    @ObservedObject var vm: UTMVirtualMachine
+    @ObservedObject var vm: VMData
     let large: Bool
     @EnvironmentObject private var data: UTMData
     
@@ -135,13 +135,13 @@ struct Screenshot: View {
         ZStack {
             Rectangle()
                 .fill(Color.black)
-            if vm.screenshot != nil {
+            if let screenshotImage = vm.screenshotImage {
                 #if os(macOS)
-                Image(nsImage: vm.screenshot!.image)
+                Image(nsImage: screenshotImage)
                     .resizable()
                     .aspectRatio(contentMode: .fit)
                 #else
-                Image(uiImage: vm.screenshot!.image)
+                Image(uiImage: screenshotImage)
                     .resizable()
                     .aspectRatio(contentMode: .fit)
                 #endif
@@ -151,7 +151,7 @@ struct Screenshot: View {
                 .blendMode(.hardLight)
             if vm.isBusy {
                 Spinner(size: .large)
-            } else if vm.state == .vmStopped {
+            } else if vm.isStopped {
                 Button(action: { data.run(vm: vm) }, label: {
                     Label("Run", systemImage: "play.circle.fill")
                         .labelStyle(.iconOnly)
@@ -164,7 +164,7 @@ struct Screenshot: View {
 }
 
 struct Details: View {
-    @ObservedObject var vm: UTMVirtualMachine
+    @ObservedObject var vm: VMData
     let sizeLabel: String
     @EnvironmentObject private var data: UTMData
     
@@ -174,7 +174,7 @@ struct Details: View {
                 HStack {
                     plainLabel("Path", systemImage: "folder")
                     Spacer()
-                    Text(vm.path.path)
+                    Text(vm.pathUrl.path)
                         .foregroundColor(.secondary)
                 }
             }
@@ -209,7 +209,7 @@ struct Details: View {
                     .foregroundColor(.secondary)
             }
             #if os(macOS)
-            if let appleConfig = vm.config.appleConfig {
+            if let appleConfig = vm.config as? UTMAppleConfiguration {
                 ForEach(appleConfig.serials) { serial in
                     if serial.mode == .ptty {
                         HStack {
@@ -221,21 +221,21 @@ struct Details: View {
                 }
             }
             #endif
-            if let qemuConfig = vm.config.qemuConfig {
+            if let qemuConfig = vm.config as? UTMQemuConfiguration {
                 ForEach(qemuConfig.serials) { serial in
                     if serial.mode == .tcpClient {
                         HStack {
                             plainLabel("Serial (Client)", systemImage: "network")
                             Spacer()
                             let address = "\(serial.tcpHostAddress ?? "example.com"):\(serial.tcpPort ?? 1234)"
-                            OptionalSelectableText(vm.state == .vmStarted ? address : nil)
+                            OptionalSelectableText(vm.wrapped?.state == .vmStarted ? address : nil)
                         }
                     } else if serial.mode == .tcpServer {
                         HStack {
                             plainLabel("Serial (Server)", systemImage: "network")
                             Spacer()
                             let address = "\(serial.tcpPort ?? 1234)"
-                            OptionalSelectableText(vm.state == .vmStarted ? address : nil)
+                            OptionalSelectableText(vm.wrapped?.state == .vmStarted ? 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: UTMVirtualMachine(newConfig: config, destinationURL: URL(fileURLWithPath: "")))
+        VMDetailsView(vm: VMData(wrapping: UTMVirtualMachine(newConfig: config, destinationURL: URL(fileURLWithPath: ""))))
         .onAppear {
             config.sharing.directoryShareMode = .webdav
             var drive = UTMQemuConfigurationDrive()

+ 2 - 2
Platform/Shared/VMNavigationListView.swift

@@ -44,8 +44,8 @@ struct VMNavigationListView: View {
     
     @ViewBuilder private var listBody: some View {
         ForEach(data.virtualMachines) { vm in
-            if let wrappedVM = vm as? UTMWrappedVirtualMachine {
-                UTMUnavailableVMView(wrappedVM: wrappedVM)
+            if !vm.isLoaded {
+                UTMUnavailableVMView(vm: vm)
             } else {
                 if #available(iOS 16, macOS 13, *) {
                     VMCardView(vm: vm)

+ 1 - 1
Platform/Shared/VMRemovableDrivesView.swift

@@ -216,7 +216,7 @@ struct VMRemovableDrivesView_Previews: PreviewProvider {
     @State static private var config = UTMQemuConfiguration()
     
     static var previews: some View {
-        VMDetailsView(vm: UTMVirtualMachine(newConfig: config, destinationURL: URL(fileURLWithPath: "")))
+        VMDetailsView(vm: VMData(wrapping: UTMVirtualMachine(newConfig: config, destinationURL: URL(fileURLWithPath: ""))))
         .onAppear {
             config.sharing.directoryShareMode = .webdav
             var drive = UTMQemuConfigurationDrive()

+ 4 - 4
Platform/Shared/VMShareFileModifier.swift

@@ -40,16 +40,16 @@ struct VMShareItemModifier: ViewModifier {
     
     enum ShareItem {
         case debugLog(URL)
-        case utmCopy(UTMVirtualMachine)
-        case utmMove(UTMVirtualMachine)
+        case utmCopy(VMData)
+        case utmMove(VMData)
         case qemuCommand(String)
         
-        func toActivityItem() -> Any {
+        @MainActor func toActivityItem() -> Any {
             switch self {
             case .debugLog(let url):
                 return url
             case .utmCopy(let vm), .utmMove(let vm):
-                return vm.path
+                return vm.pathUrl
             case .qemuCommand(let command):
                 return command
             }

+ 6 - 6
Platform/Shared/VMToolbarModifier.swift

@@ -18,7 +18,7 @@ import SwiftUI
 
 // Lots of dirty hacks to work around SwiftUI bugs introduced in Beta 2
 struct VMToolbarModifier: ViewModifier {
-    @ObservedObject var vm: UTMVirtualMachine
+    @ObservedObject var vm: VMData
     let bottom: Bool
     @State private var showSharePopup = false
     @State private var confirmAction: ConfirmAction?
@@ -55,7 +55,7 @@ struct VMToolbarModifier: ViewModifier {
                         Label("Remove", systemImage: "trash")
                             .labelStyle(.iconOnly)
                     }.help("Remove selected shortcut")
-                    .disabled(vm.state != .vmStopped)
+                    .disabled(!vm.isModifyAllowed)
                     .padding(.leading, padding)
                 } else {
                     DestructiveButton {
@@ -64,7 +64,7 @@ struct VMToolbarModifier: ViewModifier {
                         Label("Delete", systemImage: "trash")
                             .labelStyle(.iconOnly)
                     }.help("Delete selected VM")
-                    .disabled(vm.state != .vmStopped)
+                    .disabled(!vm.isModifyAllowed)
                     .padding(.leading, padding)
                 }
                 #if !os(macOS)
@@ -92,7 +92,7 @@ struct VMToolbarModifier: ViewModifier {
                         Label("Move", systemImage: "arrow.down.doc")
                             .labelStyle(.iconOnly)
                     }.help("Move selected VM")
-                    .disabled(vm.state != .vmStopped)
+                    .disabled(!vm.isModifyAllowed)
                     .padding(.leading, padding)
                 }
                 #endif
@@ -109,7 +109,7 @@ struct VMToolbarModifier: ViewModifier {
                     Spacer()
                 }
                 #endif
-                if vm.hasSaveState || vm.state != .vmStopped {
+                if vm.hasSuspendState || !vm.isStopped {
                     Button {
                         confirmAction = .confirmStopVM
                     } label: {
@@ -138,7 +138,7 @@ struct VMToolbarModifier: ViewModifier {
                     Label("Edit", systemImage: "slider.horizontal.3")
                         .labelStyle(.iconOnly)
                 }.help("Edit selected VM")
-                .disabled(vm.hasSaveState || vm.state != .vmStopped)
+                .disabled(vm.hasSuspendState || !vm.isModifyAllowed)
                 .padding(.leading, padding)
             }
         }

+ 211 - 196
Platform/UTMData.swift

@@ -24,10 +24,6 @@ import SwiftUI
 #if canImport(AltKit) && !WITH_QEMU_TCI
 import AltKit
 #endif
-#if !os(macOS)
-typealias UTMAppleConfiguration = UTMQemuConfiguration
-typealias UTMAppleVirtualMachine = UTMQemuVirtualMachine
-#endif
 
 struct AlertMessage: Identifiable {
     var message: String
@@ -40,37 +36,37 @@ struct AlertMessage: Identifiable {
     }
 }
 
-class UTMData: ObservableObject {
+@MainActor class UTMData: ObservableObject {
     
     /// Sandbox location for storing .utm bundles
-    static var defaultStorageUrl: URL {
+    nonisolated static var defaultStorageUrl: URL {
         FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
     }
     
     /// View: show VM settings
-    @MainActor @Published var showSettingsModal: Bool
+    @Published var showSettingsModal: Bool
     
     /// View: show new VM wizard
-    @MainActor @Published var showNewVMSheet: Bool
+    @Published var showNewVMSheet: Bool
     
     /// View: show an alert message
-    @MainActor @Published var alertMessage: AlertMessage?
+    @Published var alertMessage: AlertMessage?
     
     /// View: show busy spinner
-    @MainActor @Published var busy: Bool
+    @Published var busy: Bool
     
     /// View: currently selected VM
-    @MainActor @Published var selectedVM: UTMVirtualMachine?
+    @Published var selectedVM: VMData?
     
     /// View: all VMs listed, we save a bookmark to each when array is modified
-    @MainActor @Published private(set) var virtualMachines: [UTMVirtualMachine] {
+    @Published private(set) var virtualMachines: [VMData] {
         didSet {
             listSaveToDefaults()
         }
     }
     
     /// View: all pending VMs listed (ZIP and IPSW downloads)
-    @MainActor @Published private(set) var pendingVMs: [UTMPendingVirtualMachine]
+    @Published private(set) var pendingVMs: [UTMPendingVirtualMachine]
     
     #if os(macOS)
     /// View controller for every VM currently active
@@ -84,19 +80,18 @@ class UTMData: ObservableObject {
     #endif
     
     /// Shortcut for accessing FileManager.default
-    private var fileManager: FileManager {
+    nonisolated private var fileManager: FileManager {
         FileManager.default
     }
     
     /// Shortcut for accessing storage URL from instance
-    private var documentsURL: URL {
+    nonisolated private var documentsURL: URL {
         UTMData.defaultStorageUrl
     }
     
     /// Queue to run `busyWork` tasks
     private var busyQueue: DispatchQueue
     
-    @MainActor
     init() {
         self.busyQueue = DispatchQueue(label: "UTM Busy Queue", qos: .userInitiated)
         self.showSettingsModal = false
@@ -115,11 +110,11 @@ class UTMData: ObservableObject {
     /// This removes stale entries (deleted/not accessible) and duplicate entries
     func listRefresh() async {
         // wrap stale VMs
-        var list = await virtualMachines
+        var list = virtualMachines
         for i in list.indices.reversed() {
             let vm = list[i]
-            if !fileManager.fileExists(atPath: vm.path.path) {
-                list[i] = await UTMWrappedVirtualMachine(from: vm.registryEntry)
+            if let registryEntry = vm.registryEntry, !fileManager.fileExists(atPath: registryEntry.package.path) {
+                list[i] = VMData(from: registryEntry)
             }
         }
         // now look for and add new VMs in default storage
@@ -127,7 +122,7 @@ class UTMData: ObservableObject {
             let files = try fileManager.contentsOfDirectory(at: UTMData.defaultStorageUrl, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles)
             let newFiles = files.filter { newFile in
                 !list.contains { existingVM in
-                    existingVM.path.standardizedFileURL == newFile.standardizedFileURL
+                    existingVM.pathUrl.standardizedFileURL == newFile.standardizedFileURL
                 }
             }
             for file in newFiles {
@@ -137,22 +132,23 @@ class UTMData: ObservableObject {
                 guard UTMVirtualMachine.isVirtualMachine(url: file) else {
                     continue
                 }
+                await Task.yield()
                 let vm = UTMVirtualMachine(url: file)
                 if let vm = vm {
+                    let vm = VMData(wrapping: vm)
                     if uuidHasCollision(with: vm, in: list) {
-                        if let index = list.firstIndex(where: { $0 is UTMWrappedVirtualMachine && $0.id == vm.id }) {
+                        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
                             list[index] = vm
                             // update the registry with the new bookmark
-                            try? await vm.updateRegistryFromConfig()
+                            try? await vm.wrapped!.updateRegistryFromConfig()
+                            continue
                         } else {
                             // duplicate is not stale so we need a new UUID
-                            await uuidRegenerate(for: vm)
-                            list.insert(vm, at: 0)
+                            uuidRegenerate(for: vm)
                         }
-                    } else {
-                        list.insert(vm, at: 0)
                     }
+                    list.insert(vm, at: 0)
                 } else {
                     logger.error("Failed to create object for \(file)")
                 }
@@ -161,16 +157,16 @@ class UTMData: ObservableObject {
             logger.error("\(error.localizedDescription)")
         }
         // replace the VM list with our new one
-        if await virtualMachines != list {
-            await listReplace(with: list)
+        if virtualMachines != list {
+            listReplace(with: list)
         }
         // prune the registry
-        let uuids = list.map({ $0.registryEntry.uuid.uuidString })
+        let uuids = list.compactMap({ $0.registryEntry?.uuid.uuidString })
         UTMRegistry.shared.prune(exceptFor: Set(uuids))
     }
     
     /// Load VM list (and order) from persistent storage
-    @MainActor private func listLoadFromDefaults() {
+    private func listLoadFromDefaults() {
         let defaults = UserDefaults.standard
         guard defaults.object(forKey: "VMList") == nil else {
             listLegacyLoadFromDefaults()
@@ -192,66 +188,58 @@ class UTMData: ObservableObject {
             guard let entry = UTMRegistry.shared.entry(for: uuidString) else {
                 return nil
             }
-            let wrappedVM = UTMWrappedVirtualMachine(from: entry)
-            if let vm = wrappedVM.unwrap() {
-                if vm.registryEntry.uuid != wrappedVM.registryEntry.uuid {
-                    // we had a duplicate UUID so we change it
-                    vm.changeUuid(to: wrappedVM.registryEntry.uuid)
-                }
-                return vm
-            } else {
-                return wrappedVM
-            }
+            let vm = VMData(from: entry)
+            try? vm.load()
+            return vm
         }
     }
     
     /// Load VM list (and order) from persistent storage (legacy)
-    @MainActor private func listLegacyLoadFromDefaults() {
+    private func listLegacyLoadFromDefaults() {
         let defaults = UserDefaults.standard
         // legacy path list
         if let files = defaults.array(forKey: "VMList") as? [String] {
             virtualMachines = files.uniqued().compactMap({ file in
                 let url = documentsURL.appendingPathComponent(file, isDirectory: true)
-                return UTMVirtualMachine(url: url)
+                if let wrapped = UTMVirtualMachine(url: url) {
+                    return VMData(wrapping: wrapped)
+                } else {
+                    return nil
+                }
             })
         }
         // bookmark list
         if let list = defaults.array(forKey: "VMList") {
             virtualMachines = list.compactMap { item in
-                var wrappedVM: UTMWrappedVirtualMachine?
+                let vm: VMData?
                 if let bookmark = item as? Data {
-                    wrappedVM = UTMWrappedVirtualMachine(bookmark: bookmark)
+                    vm = VMData(bookmark: bookmark)
                 } else if let dict = item as? [String: Any] {
-                    wrappedVM = UTMWrappedVirtualMachine(from: dict)
-                }
-                if let wrappedVM = wrappedVM, let vm = wrappedVM.unwrap() {
-                    // legacy VMs don't have UUID stored so we made a fake UUID
-                    if wrappedVM.registryEntry.uuid != vm.registryEntry.uuid {
-                        UTMRegistry.shared.remove(entry: wrappedVM.registryEntry)
-                    }
-                    return vm
+                    vm = VMData(from: dict)
                 } else {
-                    return wrappedVM
+                    vm = nil
                 }
+                try? vm?.load()
+                return vm
             }
         }
     }
     
     /// Save VM list (and order) to persistent storage
-    @MainActor private func listSaveToDefaults() {
+    private func listSaveToDefaults() {
         let defaults = UserDefaults.standard
-        let wrappedVMs = virtualMachines.map { $0.registryEntry.uuid.uuidString }
+        let wrappedVMs = virtualMachines.map { $0.id.uuidString }
         defaults.set(wrappedVMs, forKey: "VMEntryList")
     }
     
-    @MainActor private func listReplace(with vms: [UTMVirtualMachine]) {
+    private func listReplace(with vms: [VMData]) {
         virtualMachines = vms
     }
     
     /// Add VM to list
     /// - Parameter vm: VM to add
     /// - Parameter at: Optional index to add to, otherwise will be added to the end
-    @MainActor private func listAdd(vm: UTMVirtualMachine, at index: Int? = nil) {
+    private func listAdd(vm: VMData, at index: Int? = nil) {
         if uuidHasCollision(with: vm) {
             uuidRegenerate(for: vm)
         }
@@ -264,14 +252,14 @@ class UTMData: ObservableObject {
     
     /// Select VM in list
     /// - Parameter vm: VM to select
-    @MainActor public func listSelect(vm: UTMVirtualMachine) {
+    public func listSelect(vm: VMData) {
         selectedVM = vm
     }
     
     /// Remove a VM from list
     /// - Parameter vm: VM to remove
     /// - Returns: Index of item removed or nil if already removed
-    @MainActor @discardableResult public func listRemove(vm: UTMVirtualMachine) -> Int? {
+    @discardableResult public func listRemove(vm: VMData) -> Int? {
         let index = virtualMachines.firstIndex(of: vm)
         if let index = index {
             virtualMachines.remove(at: index)
@@ -286,7 +274,7 @@ class UTMData: ObservableObject {
     /// Add pending VM to list
     /// - Parameter pendingVM: Pending VM to add
     /// - Parameter at: Optional index to add to, otherwise will be added to the end
-    @MainActor private func listAdd(pendingVM: UTMPendingVirtualMachine, at index: Int? = nil) {
+    private func listAdd(pendingVM: UTMPendingVirtualMachine, at index: Int? = nil) {
         if let index = index {
             pendingVMs.insert(pendingVM, at: index)
         } else {
@@ -297,7 +285,7 @@ class UTMData: ObservableObject {
     /// Remove pending VM from list
     /// - Parameter pendingVM: Pending VM to remove
     /// - Returns: Index of item removed or nil if already removed
-    @MainActor @discardableResult private func listRemove(pendingVM: UTMPendingVirtualMachine) -> Int? {
+    @discardableResult private func listRemove(pendingVM: UTMPendingVirtualMachine) -> Int? {
         let index = pendingVMs.firstIndex(where: { $0.id == pendingVM.id })
         if let index = index {
             pendingVMs.remove(at: index)
@@ -309,7 +297,7 @@ class UTMData: ObservableObject {
     /// - Parameters:
     ///   - fromOffsets: Offsets from move from
     ///   - toOffset: Offsets to move to
-    @MainActor func listMove(fromOffsets: IndexSet, toOffset: Int) {
+    func listMove(fromOffsets: IndexSet, toOffset: Int) {
         virtualMachines.move(fromOffsets: fromOffsets, toOffset: toOffset)
     }
     
@@ -318,7 +306,7 @@ class UTMData: ObservableObject {
     /// Generate a unique VM name
     /// - Parameter base: Base name
     /// - Returns: Unique name for a non-existing item in the default storage path
-    func newDefaultVMName(base: String = NSLocalizedString("Virtual Machine", comment: "UTMData")) -> String {
+    nonisolated func newDefaultVMName(base: String = NSLocalizedString("Virtual Machine", comment: "UTMData")) -> String {
         let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
         for i in 1..<1000 {
             let name = nameForId(i)
@@ -336,7 +324,7 @@ class UTMData: ObservableObject {
     ///   - destUrl: Destination directory where duplicates will be checked
     ///   - withExtension: Optionally change the file extension
     /// - Returns: Unique filename that is not used in the destUrl
-    static func newImage(from sourceUrl: URL, to destUrl: URL, withExtension: String? = nil) -> URL {
+    nonisolated static func newImage(from sourceUrl: URL, to destUrl: URL, withExtension: String? = nil) -> URL {
         let name = sourceUrl.deletingPathExtension().lastPathComponent
         let ext = withExtension ?? sourceUrl.pathExtension
         let strFromInt = { (i: Int) in i == 1 ? "" : "-\(i)" }
@@ -358,20 +346,20 @@ class UTMData: ObservableObject {
     
     // MARK: - Other view states
     
-    @MainActor private func setBusyIndicator(_ busy: Bool) {
+    private func setBusyIndicator(_ busy: Bool) {
         self.busy = busy
     }
     
-    @MainActor func showErrorAlert(message: String) {
+    func showErrorAlert(message: String) {
         alertMessage = AlertMessage(message)
     }
     
-    @MainActor func newVM() {
+    func newVM() {
         showSettingsModal = false
         showNewVMSheet = true
     }
     
-    @MainActor func showSettingsForCurrentVM() {
+    func showSettingsForCurrentVM() {
         #if os(iOS)
         // SwiftUI bug: cannot show modal at the same time as changing selected VM or it breaks
         DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
@@ -386,9 +374,9 @@ class UTMData: ObservableObject {
     
     /// Save an existing VM to disk
     /// - Parameter vm: VM to save
-    @MainActor func save(vm: UTMVirtualMachine) async throws {
+    func save(vm: VMData) async throws {
         do {
-            try await vm.saveUTM()
+            try await vm.save()
         } catch {
             // refresh the VM object as it is now stale
             let origError = error
@@ -396,14 +384,15 @@ class UTMData: ObservableObject {
                 try discardChanges(for: vm)
             } catch {
                 // if we can't discard changes, recreate the VM from scratch
-                let path = vm.path
-                guard let newVM = UTMVirtualMachine(url: path) else {
+                let path = vm.pathUrl
+                guard let newWrapped = UTMVirtualMachine(url: path) else {
                     logger.debug("Cannot create new object for \(path.path)")
                     throw origError
                 }
-                let index = await listRemove(vm: vm)
-                await listAdd(vm: newVM, at: index)
-                await listSelect(vm: newVM)
+                let newVM = VMData(wrapping: newWrapped)
+                let index = listRemove(vm: vm)
+                listAdd(vm: newVM, at: index)
+                listSelect(vm: newVM)
             }
             throw origError
         }
@@ -411,11 +400,11 @@ class UTMData: ObservableObject {
     
     /// Discard changes to VM configuration
     /// - Parameter vm: VM configuration to discard
-    @MainActor func discardChanges(for vm: UTMVirtualMachine? = nil) throws {
-        if let vm = vm {
-            try vm.reloadConfiguration()
+    func discardChanges(for vm: VMData) throws {
+        if let wrapped = vm.wrapped {
+            try wrapped.reloadConfiguration()
             if uuidHasCollision(with: vm) {
-                vm.changeUuid(to: UUID())
+                wrapped.changeUuid(to: UUID())
             }
         }
     }
@@ -423,61 +412,54 @@ class UTMData: ObservableObject {
     /// Save a new VM to disk
     /// - Parameters:
     ///   - config: New VM configuration
-    func create<Config: UTMConfiguration>(config: Config) async throws -> UTMVirtualMachine {
-        guard await !virtualMachines.contains(where: { !$0.isShortcut && $0.config.name == config.information.name }) else {
-            throw NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData")
-        }
-        let vm: UTMVirtualMachine
-        if config is UTMQemuConfiguration {
-            vm = UTMQemuVirtualMachine(newConfig: config, destinationURL: Self.defaultStorageUrl)
-        } else if config is UTMAppleConfiguration {
-            vm = UTMAppleVirtualMachine(newConfig: config, destinationURL: Self.defaultStorageUrl)
-        } else {
-            fatalError("Unknown configuration.")
+    func create<Config: UTMConfiguration>(config: Config) async throws -> VMData {
+        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)
         try await save(vm: vm)
-        await listAdd(vm: vm)
-        await listSelect(vm: vm)
+        listAdd(vm: vm)
+        listSelect(vm: vm)
         return vm
     }
     
     /// Delete a VM from disk
     /// - Parameter vm: VM to delete
     /// - Returns: Index of item removed in VM list or nil if not in list
-    @discardableResult func delete(vm: UTMVirtualMachine, alsoRegistry: Bool = true) async throws -> Int? {
-        if let _ = vm as? UTMWrappedVirtualMachine {
-        } else {
-            try fileManager.removeItem(at: vm.path)
+    @discardableResult func delete(vm: VMData, alsoRegistry: Bool = true) async throws -> Int? {
+        if vm.isLoaded {
+            try fileManager.removeItem(at: vm.pathUrl)
         }
         
         // close any open window
         close(vm: vm)
         
-        if alsoRegistry {
-            UTMRegistry.shared.remove(entry: vm.registryEntry)
+        if alsoRegistry, let registryEntry = vm.registryEntry {
+            UTMRegistry.shared.remove(entry: registryEntry)
         }
-        return await listRemove(vm: vm)
+        return listRemove(vm: vm)
     }
     
     /// Save a copy of the VM and all data to default storage location
     /// - Parameter vm: VM to clone
     /// - Returns: The new VM
-    @discardableResult func clone(vm: UTMVirtualMachine) async throws -> UTMVirtualMachine {
+    @discardableResult func clone(vm: VMData) async throws -> VMData {
         let newName: String = newDefaultVMName(base: vm.detailsTitleLabel)
         let newPath = UTMVirtualMachine.virtualMachinePath(newName, inParentURL: documentsURL)
         
-        try copyItemWithCopyfile(at: vm.path, to: newPath)
-        guard let newVM = UTMVirtualMachine(url: newPath) else {
-            throw NSLocalizedString("Failed to clone VM.", comment: "UTMData")
-        }
-        await newVM.changeUuid(to: UUID(), name: newName)
-        try await newVM.saveUTM()
-        var index = await virtualMachines.firstIndex(of: vm)
+        try copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
+        guard let wrapped = UTMVirtualMachine(url: newPath) else {
+            throw UTMDataError.cloneFailed
+        }
+        wrapped.changeUuid(to: UUID(), name: newName)
+        let newVM = VMData(wrapping: wrapped)
+        try await newVM.save()
+        var index = virtualMachines.firstIndex(of: vm)
         if index != nil {
             index! += 1
         }
-        await listAdd(vm: newVM, at: index)
-        await listSelect(vm: newVM)
+        listAdd(vm: newVM, at: index)
+        listSelect(vm: newVM)
         return newVM
     }
     
@@ -485,8 +467,8 @@ class UTMData: ObservableObject {
     /// - Parameters:
     ///   - vm: VM to copy
     ///   - url: Location to copy to (must be writable)
-    func export(vm: UTMVirtualMachine, to url: URL) throws {
-        let sourceUrl = vm.path
+    func export(vm: VMData, to url: URL) throws {
+        let sourceUrl = vm.pathUrl
         if fileManager.fileExists(atPath: url.path) {
             try fileManager.removeItem(at: url)
         }
@@ -497,28 +479,27 @@ class UTMData: ObservableObject {
     /// - Parameters:
     ///   - vm: VM to move
     ///   - url: Location to move to (must be writable)
-    func move(vm: UTMVirtualMachine, to url: URL) async throws {
+    func move(vm: VMData, to url: URL) async throws {
         try export(vm: vm, to: url)
-        guard let newVM = UTMVirtualMachine(url: url) else {
-            throw NSLocalizedString("Unable to add a shortcut to the new location.", comment: "UTMData")
-        }
-        await MainActor.run {
-            newVM.isShortcut = true
+        guard let wrapped = UTMVirtualMachine(url: url) else {
+            throw UTMDataError.shortcutCreationFailed
         }
-        try await newVM.updateRegistryFromConfig()
-        try await newVM.accessShortcut()
+        wrapped.isShortcut = true
+        try await wrapped.updateRegistryFromConfig()
+        try await wrapped.accessShortcut()
+        let newVM = VMData(wrapping: wrapped)
         
-        let oldSelected = await selectedVM
+        let oldSelected = selectedVM
         let index = try await delete(vm: vm, alsoRegistry: false)
-        await listAdd(vm: newVM, at: index)
+        listAdd(vm: newVM, at: index)
         if oldSelected == vm {
-            await listSelect(vm: newVM)
+            listSelect(vm: newVM)
         }
     }
     
     /// Open settings modal
     /// - Parameter vm: VM to edit settings
-    @MainActor func edit(vm: UTMVirtualMachine) {
+    func edit(vm: VMData) {
         listSelect(vm: vm)
         showNewVMSheet = false
         showSettingsForCurrentVM()
@@ -526,21 +507,22 @@ class UTMData: ObservableObject {
     
     /// Copy configuration but not data from existing VM to a new VM
     /// - Parameter vm: Existing VM to copy configuration from
-    @MainActor func template(vm: UTMVirtualMachine) async throws {
-        let copy = try UTMQemuConfiguration.load(from: vm.path)
+    func template(vm: VMData) async throws {
+        let copy = try UTMQemuConfiguration.load(from: vm.pathUrl)
         if let copy = copy as? UTMQemuConfiguration {
             copy.information.name = self.newDefaultVMName(base: copy.information.name)
             copy.information.uuid = UUID()
             copy.drives = []
             _ = try await create(config: copy)
-        } else if let copy = copy as? UTMAppleConfiguration {
+        }
+        #if os(macOS)
+        if let copy = copy as? UTMAppleConfiguration {
             copy.information.name = self.newDefaultVMName(base: copy.information.name)
             copy.information.uuid = UUID()
             copy.drives = []
             _ = try await create(config: copy)
-        } else {
-            fatalError()
         }
+        #endif
         showSettingsForCurrentVM()
     }
     
@@ -549,8 +531,8 @@ class UTMData: ObservableObject {
     /// Calculate total size of VM and data
     /// - Parameter vm: VM to calculate size
     /// - Returns: Size in bytes
-    func computeSize(for vm: UTMVirtualMachine) -> Int64 {
-        let path = vm.path
+    func computeSize(for vm: VMData) -> Int64 {
+        let path = vm.pathUrl
         guard let enumerator = fileManager.enumerator(at: path, includingPropertiesForKeys: [.totalFileSizeKey]) else {
             logger.error("failed to create enumerator for \(path)")
             return 0
@@ -596,51 +578,44 @@ class UTMData: ObservableObject {
         let fileBasePath = url.deletingLastPathComponent()
         let fileName = url.lastPathComponent
         let dest = documentsURL.appendingPathComponent(fileName, isDirectory: true)
-        if let vm = await virtualMachines.first(where: { vm -> Bool in
-            return vm.path.standardizedFileURL == url.standardizedFileURL
+        if let vm = virtualMachines.first(where: { vm -> Bool in
+            return vm.pathUrl.standardizedFileURL == url.standardizedFileURL
         }) {
             logger.info("found existing vm!")
-            if let wrappedVM = vm as? UTMWrappedVirtualMachine {
+            if !vm.isLoaded {
                 logger.info("existing vm is wrapped")
-                await MainActor.run {
-                    if let unwrappedVM = wrappedVM.unwrap() {
-                        let index = listRemove(vm: wrappedVM)
-                        listAdd(vm: unwrappedVM, at: index)
-                        listSelect(vm: unwrappedVM)
-                    }
-                }
+                try vm.load()
             } else {
                 logger.info("existing vm is not wrapped")
-                await listSelect(vm: vm)
+                listSelect(vm: vm)
             }
             return
         }
         // check if VM is valid
         guard let _ = UTMVirtualMachine(url: url) else {
-            throw NSLocalizedString("Cannot import this VM. Either the configuration is invalid, created in a newer version of UTM, or on a platform that is incompatible with this version of UTM.", comment: "UTMData")
+            throw UTMDataError.importFailed
         }
-        let vm: UTMVirtualMachine?
+        let wrapped: UTMVirtualMachine?
         if (fileBasePath.resolvingSymlinksInPath().path == documentsURL.appendingPathComponent("Inbox", isDirectory: true).path) {
             logger.info("moving from Inbox")
             try fileManager.moveItem(at: url, to: dest)
-            vm = UTMVirtualMachine(url: dest)
+            wrapped = UTMVirtualMachine(url: dest)
         } else if asShortcut {
             logger.info("loading as a shortcut")
-            vm = UTMVirtualMachine(url: url)
-            await MainActor.run {
-                vm?.isShortcut = true
-            }
-            try await vm?.accessShortcut()
+            wrapped = UTMVirtualMachine(url: url)
+            wrapped?.isShortcut = true
+            try await wrapped?.accessShortcut()
         } else {
             logger.info("copying to Documents")
             try fileManager.copyItem(at: url, to: dest)
-            vm = UTMVirtualMachine(url: dest)
+            wrapped = UTMVirtualMachine(url: dest)
         }
-        guard let vm = vm else {
-            throw NSLocalizedString("Failed to parse imported VM.", comment: "UTMData")
+        guard let wrapped = wrapped else {
+            throw UTMDataError.importParseFailed
         }
-        await listAdd(vm: vm)
-        await listSelect(vm: vm)
+        let vm = VMData(wrapping: wrapped)
+        listAdd(vm: vm)
+        listSelect(vm: vm)
     }
 
     func copyItemWithCopyfile(at srcURL: URL, to dstURL: URL) throws {
@@ -658,30 +633,29 @@ class UTMData: ObservableObject {
     /// Create a new VM using configuration and downloaded IPSW
     /// - Parameter config: Apple VM configuration
     @available(macOS 12, *)
-    @MainActor func downloadIPSW(using config: UTMAppleConfiguration) {
+    func downloadIPSW(using config: UTMAppleConfiguration) async {
         let task = UTMDownloadIPSWTask(for: config)
-        guard !virtualMachines.contains(where: { !$0.isShortcut && $0.config.name == config.information.name }) else {
+        guard !virtualMachines.contains(where: { !$0.isShortcut && $0.config?.information.name == config.information.name }) else {
             showErrorAlert(message: NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData"))
             return
         }
         listAdd(pendingVM: task.pendingVM)
-        Task {
-            do {
-                if let vm = try await task.download() {
-                    try await self.save(vm: vm)
-                    listAdd(vm: vm)
-                }
-            } catch {
-                showErrorAlert(message: error.localizedDescription)
+        do {
+            if let wrapped = try await task.download() {
+                let vm = VMData(wrapping: wrapped)
+                try await self.save(vm: vm)
+                listAdd(vm: vm)
             }
-            listRemove(pendingVM: task.pendingVM)
+        } catch {
+            showErrorAlert(message: error.localizedDescription)
         }
+        listRemove(pendingVM: task.pendingVM)
     }
     #endif
 
     /// Create a new VM by downloading a .zip and extracting it
     /// - Parameter components: Download URL components
-    @MainActor func downloadUTMZip(from components: URLComponents) {
+    func downloadUTMZip(from components: URLComponents) async {
         guard let urlParameter = components.queryItems?.first(where: { $0.name == "url" })?.value,
            let url = URL(string: urlParameter) else {
                showErrorAlert(message: NSLocalizedString("Failed to parse download URL.", comment: "UTMData"))
@@ -689,34 +663,32 @@ class UTMData: ObservableObject {
         }
         let task = UTMDownloadVMTask(for: url)
         listAdd(pendingVM: task.pendingVM)
-        Task {
-            do {
-                if let vm = try await task.download() {
-                    listAdd(vm: vm)
-                }
-            } catch {
-                showErrorAlert(message: error.localizedDescription)
+        do {
+            if let wrapped = try await task.download() {
+                let vm = VMData(wrapping: wrapped)
+                try await self.save(vm: vm)
+                listAdd(vm: vm)
             }
-            listRemove(pendingVM: task.pendingVM)
+        } catch {
+            showErrorAlert(message: error.localizedDescription)
         }
+        listRemove(pendingVM: task.pendingVM)
     }
     
-    @MainActor func mountSupportTools(for vm: UTMQemuVirtualMachine) async throws {
+    func mountSupportTools(for vm: UTMQemuVirtualMachine) async throws {
         let task = UTMDownloadSupportToolsTask(for: vm)
         if task.hasExistingSupportTools {
             vm.isGuestToolsInstallRequested = false
             _ = try await task.mountTools()
         } else {
             listAdd(pendingVM: task.pendingVM)
-            Task {
-                do {
-                    _ = try await task.download()
-                } catch {
-                    showErrorAlert(message: error.localizedDescription)
-                }
-                vm.isGuestToolsInstallRequested = false
-                listRemove(pendingVM: task.pendingVM)
+            do {
+                _ = try await task.download()
+            } catch {
+                showErrorAlert(message: error.localizedDescription)
             }
+            vm.isGuestToolsInstallRequested = false
+            listRemove(pendingVM: task.pendingVM)
         }
     }
     
@@ -759,25 +731,27 @@ class UTMData: ObservableObject {
     
     // MARK: - UUID migration
     
-    @MainActor private func uuidHasCollision(with vm: UTMVirtualMachine) -> Bool {
+    private func uuidHasCollision(with vm: VMData) -> Bool {
         return uuidHasCollision(with: vm, in: virtualMachines)
     }
     
-    private func uuidHasCollision(with vm: UTMVirtualMachine, in list: [UTMVirtualMachine]) -> Bool {
+    private func uuidHasCollision(with vm: VMData, in list: [VMData]) -> Bool {
         for otherVM in list {
             if otherVM == vm {
                 return false
-            } else if otherVM.registryEntry.uuid == vm.registryEntry.uuid {
+            } else if let lhs = otherVM.registryEntry?.uuid, let rhs = vm.registryEntry?.uuid, lhs == rhs {
                 return true
             }
         }
         return false
     }
     
-    @MainActor private func uuidRegenerate(for vm: UTMVirtualMachine) {
-        let oldEntry = vm.registryEntry
-        vm.changeUuid(to: UUID())
-        vm.registryEntry.update(copying: oldEntry)
+    private func uuidRegenerate(for vm: VMData) {
+        guard let vm = vm.wrapped else {
+            return
+        }
+        let previous = vm.registryEntry
+        vm.changeUuid(to: UUID(), copyFromExisting: previous)
     }
     
     // MARK: - Other utility functions
@@ -846,7 +820,7 @@ class UTMData: ObservableObject {
     /// - Parameters:
     ///   - vm: VM to send mouse/tablet coordinates to
     ///   - components: Data (see UTM Wiki for details)
-    @MainActor func automationSendMouse(to vm: UTMVirtualMachine, urlComponents components: URLComponents) {
+    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 }
         guard let queryItems = components.queryItems else { return }
@@ -938,9 +912,9 @@ class UTMData: ObservableObject {
             ServerManager.shared.stopDiscovering()
         }
         if event.wait(timeout: .now() + 10) == .timedOut {
-            throw NSLocalizedString("Cannot find AltServer for JIT enable. You cannot run VMs until JIT is enabled.", comment: "UTMData")
+            throw UTMDataError.altServerNotFound
         } else if let error = connectError {
-            throw String.localizedStringWithFormat(NSLocalizedString("AltJIT error: %@", comment: "UTMData"), error.localizedDescription)
+            throw UTMDataError.altJitError(error.localizedDescription)
         }
     }
 #endif
@@ -969,15 +943,15 @@ class UTMData: ObservableObject {
                     Main.jitAvailable = true
                 }
             } catch is DecodingError {
-                throw NSLocalizedString("Failed to decode JitStreamer response.", comment: "ContentView")
+                throw UTMDataError.jitStreamerDecodeFailed
             } catch {
-                throw NSLocalizedString("Failed to attach to JitStreamer.", comment: "ContentView")
+                throw UTMDataError.jitStreamerAttachFailed
             }
             if let attachError = attachError {
                 throw attachError
             }
         } else {
-            throw String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "ContentView"), urlString)
+            throw UTMDataError.jitStreamerUrlInvalid(urlString)
         }
     }
 
@@ -987,3 +961,44 @@ class UTMData: ObservableObject {
     }
 #endif
 }
+
+// MARK: - Errors
+enum UTMDataError: Error {
+    case virtualMachineAlreadyExists
+    case cloneFailed
+    case shortcutCreationFailed
+    case importFailed
+    case importParseFailed
+    case altServerNotFound
+    case altJitError(String)
+    case jitStreamerDecodeFailed
+    case jitStreamerAttachFailed
+    case jitStreamerUrlInvalid(String)
+}
+
+extension UTMDataError: LocalizedError {
+    var errorDescription: String? {
+        switch self {
+        case .virtualMachineAlreadyExists:
+            return NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData")
+        case .cloneFailed:
+            return NSLocalizedString("Failed to clone VM.", comment: "UTMData")
+        case .shortcutCreationFailed:
+            return NSLocalizedString("Unable to add a shortcut to the new location.", comment: "UTMData")
+        case .importFailed:
+            return NSLocalizedString("Cannot import this VM. Either the configuration is invalid, created in a newer version of UTM, or on a platform that is incompatible with this version of UTM.", comment: "UTMData")
+        case .importParseFailed:
+            return NSLocalizedString("Failed to parse imported VM.", comment: "UTMData")
+        case .altServerNotFound:
+            return NSLocalizedString("Cannot find AltServer for JIT enable. You cannot run VMs until JIT is enabled.", comment: "UTMData")
+        case .altJitError(let message):
+            return String.localizedStringWithFormat(NSLocalizedString("AltJIT error: %@", comment: "UTMData"), message)
+        case .jitStreamerDecodeFailed:
+            return NSLocalizedString("Failed to decode JitStreamer response.", comment: "UTMData")
+        case .jitStreamerAttachFailed:
+            return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData")
+        case .jitStreamerUrlInvalid(let urlString):
+            return String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "UTMData"), urlString)
+        }
+    }
+}

+ 401 - 0
Platform/VMData.swift

@@ -0,0 +1,401 @@
+//
+// 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.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Combine
+import SwiftUI
+
+/// Model wrapping a single UTMVirtualMachine for use in views
+@MainActor class VMData: ObservableObject {
+    /// Underlying virtual machine
+    private(set) var wrapped: UTMVirtualMachine? {
+        willSet {
+            objectWillChange.send()
+        }
+        
+        didSet {
+            subscribeToChildren()
+        }
+    }
+    
+    /// Virtual machine configuration
+    var config: (any UTMConfiguration)? {
+        wrapped?.config.wrappedValue as? (any UTMConfiguration)
+    }
+    
+    /// Current path of the VM
+    var pathUrl: URL {
+        if let wrapped = wrapped {
+            return wrapped.path
+        } else if let registryEntry = registryEntry {
+            return registryEntry.package.url
+        } else {
+            fatalError()
+        }
+    }
+    
+    /// Virtual machine state
+    var registryEntry: UTMRegistryEntry? {
+        wrapped?.registryEntry ??
+        registryEntryWrapped
+    }
+    
+    /// Registry entry before loading
+    private var registryEntryWrapped: UTMRegistryEntry?
+    
+    /// Set when we use a temporary UUID because we loaded a legacy entry
+    private var uuidUnknown: Bool = false
+    
+    /// Display VM as "deleted" for UI elements
+    ///
+    /// This is a workaround for SwiftUI bugs not hiding deleted elements.
+    @Published var isDeleted: Bool = false
+    
+    /// Allows changes in the config, registry, and VM to be reflected
+    private var observers: [AnyCancellable] = []
+    
+    /// No default init
+    private init() {
+        
+    }
+    
+    /// Create a VM from an existing object
+    /// - Parameter vm: VM to wrap
+    convenience init(wrapping vm: UTMVirtualMachine) {
+        self.init()
+        self.wrapped = vm
+        subscribeToChildren()
+    }
+    
+    /// Create a new wrapped UTM VM from a registry entry
+    /// - Parameter registryEntry: Registry entry
+    convenience init(from registryEntry: UTMRegistryEntry) {
+        self.init()
+        self.registryEntryWrapped = registryEntry
+        subscribeToChildren()
+    }
+    
+    /// Create a new wrapped UTM VM from a dictionary (legacy support)
+    /// - Parameter info: Dictionary info
+    convenience init?(from info: [String: Any]) {
+        guard let bookmark = info["Bookmark"] as? Data,
+              let name = info["Name"] as? String,
+              let pathString = info["Path"] as? String else {
+            return nil
+        }
+        let legacyEntry = UTMRegistry.shared.entry(uuid: UUID(), name: name, path: pathString, bookmark: bookmark)
+        self.init(from: legacyEntry)
+        uuidUnknown = true
+    }
+    
+    /// Create a new wrapped UTM VM from only the bookmark data (legacy support)
+    /// - Parameter bookmark: Bookmark data
+    convenience init(bookmark: Data) {
+        self.init()
+        let uuid = UUID()
+        let name = NSLocalizedString("(Unavailable)", comment: "VMData")
+        let pathString = "/\(UUID().uuidString)"
+        let legacyEntry = UTMRegistry.shared.entry(uuid: uuid, name: name, path: pathString, bookmark: bookmark)
+        self.init(from: legacyEntry)
+        uuidUnknown = true
+    }
+    
+    /// Create a new VM from a configuration
+    /// - Parameter config: Configuration to create new VM
+    convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) {
+        self.init()
+        if config is UTMQemuConfiguration {
+            wrapped = UTMQemuVirtualMachine(newConfig: config, destinationURL: destinationUrl)
+        }
+        #if os(macOS)
+        if config is UTMAppleConfiguration {
+            wrapped = UTMAppleVirtualMachine(newConfig: config, destinationURL: destinationUrl)
+        }
+        #endif
+        subscribeToChildren()
+    }
+    
+    /// Loads the VM from file
+    ///
+    /// If the VM is already loaded, it will return true without doing anything.
+    /// - Returns: If load was successful
+    func load() throws {
+        guard !isLoaded else {
+            return
+        }
+        guard let vm = UTMVirtualMachine(url: pathUrl) 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)
+            }
+        }
+        wrapped = vm
+        uuidUnknown = false
+    }
+    
+    /// Saves the VM to file
+    func save() async throws {
+        guard let wrapped = wrapped else {
+            throw VMDataError.virtualMachineNotLoaded
+        }
+        try await wrapped.saveUTM()
+    }
+    
+    /// 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
+                self?.objectWillChange.send()
+            })
+        }
+        #if os(macOS)
+        if let config = config as? UTMAppleConfiguration {
+            s.append(config.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()
+            })
+        }
+        observers = s
+    }
+}
+
+// MARK: - Errors
+enum VMDataError: Error {
+    case virtualMachineNotLoaded
+}
+
+extension VMDataError: LocalizedError {
+    var errorDescription: String? {
+        switch self {
+        case .virtualMachineNotLoaded:
+            return NSLocalizedString("Virtual machine not loaded.", comment: "VMData")
+        }
+    }
+}
+
+// MARK: - Identity
+extension VMData: Identifiable {
+    public var id: UUID {
+        registryEntry?.uuid ??
+        config?.information.uuid ??
+        UUID()
+    }
+}
+
+extension VMData: Equatable {
+    static func == (lhs: VMData, rhs: VMData) -> Bool {
+        if lhs.isLoaded && rhs.isLoaded {
+            return lhs.wrapped == rhs.wrapped
+        }
+        if let lhsEntry = lhs.registryEntryWrapped, let rhsEntry = rhs.registryEntryWrapped {
+            return lhsEntry == rhsEntry
+        }
+        return false
+    }
+}
+
+extension VMData: Hashable {
+    func hash(into hasher: inout Hasher) {
+        hasher.combine(wrapped)
+        hasher.combine(registryEntryWrapped)
+        hasher.combine(isDeleted)
+    }
+}
+
+// MARK: - VM State
+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 {
+        wrapped != nil
+    }
+    
+    /// VM is stopped
+    var isStopped: Bool {
+        if let state = wrapped?.state {
+            return state == .vmStopped || state == .vmPaused
+        } else {
+            return true
+        }
+    }
+    
+    /// VM can be modified
+    var isModifyAllowed: Bool {
+        if let state = wrapped?.state {
+            return state == .vmStopped
+        } else {
+            return false
+        }
+    }
+    
+    /// Display VM as "busy" for UI elements
+    var isBusy: Bool {
+        wrapped?.state == .vmPausing ||
+        wrapped?.state == .vmResuming ||
+        wrapped?.state == .vmStarting ||
+        wrapped?.state == .vmStopping
+    }
+    
+    /// VM has been suspended before
+    var hasSuspendState: Bool {
+        registryEntry?.isSuspended ?? false
+    }
+}
+
+// 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")
+    }
+    
+    /// Display title for UI elements
+    var detailsTitleLabel: String {
+        config?.information.name ??
+        registryEntry?.name ??
+        unavailable
+    }
+    
+    /// Display subtitle for UI elements
+    var detailsSubtitleLabel: String {
+        detailsSystemTargetLabel
+    }
+    
+    /// Display icon path for UI elements
+    var detailsIconUrl: URL? {
+        config?.information.iconURL ?? nil
+    }
+    
+    /// Display user-specified notes for UI elements
+    var detailsNotes: String? {
+        config?.information.notes ?? nil
+    }
+    
+    /// Display VM target system for UI elements
+    var detailsSystemTargetLabel: String {
+        if let qemuConfig = config as? UTMQemuConfiguration {
+            return qemuConfig.system.target.prettyValue
+        }
+        #if os(macOS)
+        if let appleConfig = config as? UTMAppleConfiguration {
+            return appleConfig.system.boot.operatingSystem.rawValue
+        }
+        #endif
+        return unavailable
+    }
+    
+    /// Display VM architecture for UI elements
+    var detailsSystemArchitectureLabel: String {
+        if let qemuConfig = config as? UTMQemuConfiguration {
+            return qemuConfig.system.architecture.prettyValue
+        }
+        #if os(macOS)
+        if let appleConfig = config as? UTMAppleConfiguration {
+            return appleConfig.system.architecture
+        }
+        #endif
+        return unavailable
+    }
+    
+    /// Display RAM (formatted) for UI elements
+    var detailsSystemMemoryLabel: String {
+        let bytesInMib = Int64(1048576)
+        if let qemuConfig = config as? UTMQemuConfiguration {
+            return ByteCountFormatter.string(fromByteCount: Int64(qemuConfig.system.memorySize) * bytesInMib, countStyle: .binary)
+        }
+        #if os(macOS)
+        if let appleConfig = config as? UTMAppleConfiguration {
+            return ByteCountFormatter.string(fromByteCount: Int64(appleConfig.system.memorySize) * bytesInMib, countStyle: .binary)
+        }
+        #endif
+        return unavailable
+    }
+    
+    /// 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:
+            if registryEntry?.hasSaveState == true {
+                return NSLocalizedString("Suspended", comment: "VMData");
+            } else {
+                return NSLocalizedString("Stopped", comment: "VMData");
+            }
+        case .vmStarting:
+            return NSLocalizedString("Starting", comment: "VMData")
+        case .vmStarted:
+            return NSLocalizedString("Started", comment: "VMData")
+        case .vmPausing:
+            return NSLocalizedString("Pausing", comment: "VMData")
+        case .vmPaused:
+            return NSLocalizedString("Paused", comment: "VMData")
+        case .vmResuming:
+            return NSLocalizedString("Resuming", comment: "VMData")
+        case .vmStopping:
+            return NSLocalizedString("Stopping", comment: "VMData")
+        @unknown default:
+            fatalError()
+        }
+    }
+    
+    /// If non-null, is the most recent screenshot image of the running VM
+    var screenshotImage: PlatformImage? {
+        wrapped?.screenshot?.image
+    }
+}

+ 12 - 6
Platform/iOS/UTMDataExtension.swift

@@ -18,18 +18,24 @@ import Foundation
 import SwiftUI
 
 extension UTMData {
-    @MainActor func run(vm: UTMVirtualMachine) {
-        let session = VMSessionState(for: vm as! UTMQemuVirtualMachine)
+    func run(vm: VMData) {
+        guard let wrapped = vm.wrapped else {
+            return
+        }
+        let session = VMSessionState(for: wrapped as! UTMQemuVirtualMachine)
         session.start()
     }
     
-    func stop(vm: UTMVirtualMachine) {
-        if vm.hasSaveState {
-            vm.requestVmDeleteState()
+    func stop(vm: VMData) {
+        guard let wrapped = vm.wrapped else {
+            return
+        }
+        if wrapped.hasSaveState {
+            wrapped.requestVmDeleteState()
         }
     }
     
-    func close(vm: UTMVirtualMachine) {
+    func close(vm: VMData) {
         // do nothing
     }
     

+ 2 - 2
Platform/iOS/VMSettingsView.swift

@@ -17,7 +17,7 @@
 import SwiftUI
 
 struct VMSettingsView: View {
-    let vm: UTMVirtualMachine
+    let vm: VMData
     @ObservedObject var config: UTMQemuConfiguration
     
     @State private var isResetConfig: Bool = false
@@ -242,6 +242,6 @@ struct VMSettingsView_Previews: PreviewProvider {
     @State static private var config = UTMQemuConfiguration()
     
     static var previews: some View {
-        VMSettingsView(vm: UTMVirtualMachine(), config: config)
+        VMSettingsView(vm: VMData(wrapping: UTMVirtualMachine()), config: config)
     }
 }

+ 3 - 2
Platform/iOS/VMWizardView.swift

@@ -94,11 +94,12 @@ fileprivate struct WizardWrapper: View {
                         data.busyWorkAsync {
                             let config = try await wizardState.generateConfig()
                             if let qemuConfig = config.qemuConfig {
-                                let vm = try await data.create(config: qemuConfig) as! UTMQemuVirtualMachine
+                                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 {
-                                        vm.isGuestToolsInstallRequested = wizardState.isGuestToolsInstallRequested
+                                        wrapped.isGuestToolsInstallRequested = wizardState.isGuestToolsInstallRequested
                                     }
                                 }
                             } else {

+ 1 - 1
Platform/macOS/AppDelegate.swift

@@ -14,7 +14,7 @@
 // limitations under the License.
 //
 
-class AppDelegate: NSObject, NSApplicationDelegate {
+@MainActor class AppDelegate: NSObject, NSApplicationDelegate {
     var data: UTMData?
     
     @Setting("KeepRunningAfterLastWindowClosed") private var isKeepRunningAfterLastWindowClosed: Bool = false

+ 1 - 1
Platform/macOS/SavePanel.swift

@@ -49,7 +49,7 @@ struct SavePanel: NSViewRepresentable {
                 savePanel.allowedContentTypes = [.appleLog]
             case .utmCopy(let vm), .utmMove(let vm):
                 savePanel.title = NSLocalizedString("Select where to save UTM Virtual Machine:", comment: "SavePanel")
-                savePanel.nameFieldStringValue = vm.path.lastPathComponent
+                savePanel.nameFieldStringValue = vm.pathUrl.lastPathComponent
                 savePanel.allowedContentTypes = [.UTM]
             case .qemuCommand:
                 savePanel.title = NSLocalizedString("Select where to export QEMU command:", comment: "SavePanel")

+ 21 - 8
Platform/macOS/UTMDataExtension.swift

@@ -19,7 +19,10 @@ import Carbon.HIToolbox
 
 @available(macOS 11, *)
 extension UTMData {
-    @MainActor func run(vm: UTMVirtualMachine, startImmediately: Bool = true) {
+    func run(vm: VMData, startImmediately: Bool = true) {
+        guard let vm = vm.wrapped else {
+            return
+        }
         var window: Any? = vmWindows[vm]
         if window == nil {
             let close = { (notification: Notification) -> Void in
@@ -65,24 +68,34 @@ extension UTMData {
         } else if let unwrappedWindow = window as? VMHeadlessSessionState {
             vmWindows[vm] = unwrappedWindow
             if startImmediately {
-                vm.requestVmStart()
+                if vm.state == .vmPaused {
+                    vm.requestVmResume()
+                } else {
+                    vm.requestVmStart()
+                }
             }
         } else {
             logger.critical("Failed to create window controller.")
         }
     }
     
-    func stop(vm: UTMVirtualMachine) {
-        if vm.hasSaveState {
-            vm.requestVmDeleteState()
+    func stop(vm: VMData) {
+        guard let wrapped = vm.wrapped else {
+            return
         }
-        vm.vmStop(force: false, completion: { _ in
+        if wrapped.hasSaveState {
+            wrapped.requestVmDeleteState()
+        }
+        wrapped.vmStop(force: false, completion: { _ in
             self.close(vm: vm)
         })
     }
     
-    func close(vm: UTMVirtualMachine) {
-        if let window = vmWindows.removeValue(forKey: vm) as? VMDisplayWindowController {
+    func close(vm: VMData) {
+        guard let wrapped = vm.wrapped else {
+            return
+        }
+        if let window = vmWindows.removeValue(forKey: wrapped) as? VMDisplayWindowController {
             DispatchQueue.main.async {
                 window.close()
             }

+ 6 - 6
Platform/macOS/UTMMenuBarExtraScene.swift

@@ -52,25 +52,25 @@ struct UTMMenuBarExtraScene: Scene {
 }
 
 private struct VMMenuItem: View {
-    @ObservedObject var vm: UTMVirtualMachine
+    @ObservedObject var vm: VMData
     @EnvironmentObject private var data: UTMData
     
     var body: some View {
         Menu(vm.detailsTitleLabel) {
-            if vm.state == .vmStopped || vm.state == .vmPaused {
+            if vm.isStopped {
                 Button("Start") {
                     data.run(vm: vm)
                 }
-            } else if vm.state == .vmStarted {
+            } else if !vm.isBusy {
                 Button("Stop") {
                     data.stop(vm: vm)
                 }
                 Button("Suspend") {
-                    let isSnapshot = (vm as? UTMQemuVirtualMachine)?.isRunningAsSnapshot ?? false
-                    vm.requestVmPause(save: !isSnapshot)
+                    let isSnapshot = (vm.wrapped as? UTMQemuVirtualMachine)?.isRunningAsSnapshot ?? false
+                    vm.wrapped!.requestVmPause(save: !isSnapshot)
                 }
                 Button("Reset") {
-                    vm.requestVmReset()
+                    vm.wrapped!.requestVmReset()
                 }
             } else {
                 Text("Busy…")

+ 6 - 4
Platform/macOS/VMSettingsView.swift

@@ -18,7 +18,7 @@ import SwiftUI
 
 @available(macOS 11, *)
 struct VMSettingsView<Config: UTMConfiguration>: View {
-    let vm: UTMVirtualMachine?
+    let vm: VMData?
     @ObservedObject var config: Config
     
     @EnvironmentObject private var data: UTMData
@@ -51,7 +51,7 @@ struct VMSettingsView<Config: UTMConfiguration>: View {
     
     func save() {
         data.busyWorkAsync {
-            if let existing = await self.vm {
+            if let existing = self.vm {
                 try await data.save(vm: existing)
             } else {
                 _ = try await data.create(config: self.config)
@@ -64,8 +64,10 @@ struct VMSettingsView<Config: UTMConfiguration>: View {
     
     func cancel() {
         presentationMode.wrappedValue.dismiss()
-        data.busyWorkAsync {
-            try await data.discardChanges(for: self.vm)
+        if let vm = vm {
+            data.busyWorkAsync {
+                try await data.discardChanges(for: vm)
+            }
         }
     }
 }

+ 3 - 2
Platform/macOS/VMWizardView.swift

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

+ 1 - 1
Scripting/UTMScriptingConfigImpl.swift

@@ -33,7 +33,7 @@ import Foundation
             }
             let wrapper = UTMScriptingConfigImpl(vm.config.wrappedValue as! any UTMConfiguration)
             try wrapper.updateConfiguration(from: newConfiguration)
-            try await data.save(vm: vm)
+            try await data.save(vm: box)
         }
     }
 }

+ 10 - 7
Scripting/UTMScriptingVirtualMachineImpl.swift

@@ -20,8 +20,11 @@ import QEMUKitInternal
 @MainActor
 @objc(UTMScriptingVirtualMachineImpl)
 class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
-    @nonobjc var vm: UTMVirtualMachine
+    @nonobjc var box: VMData
     @nonobjc var data: UTMData
+    @nonobjc var vm: UTMVirtualMachine! {
+        box.wrapped
+    }
     
     @objc var id: String {
         vm.id.uuidString
@@ -94,8 +97,8 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
                                    uniqueID: id)
     }
     
-    init(for vm: UTMVirtualMachine, data: UTMData) {
-        self.vm = vm
+    init(for vm: VMData, data: UTMData) {
+        self.box = vm
         self.data = data
     }
     
@@ -108,7 +111,7 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
                 }
                 vm.isRunningAsSnapshot = true
             }
-            data.run(vm: vm, startImmediately: false)
+            data.run(vm: box, startImmediately: false)
             if vm.state == .vmStopped {
                 try await vm.vmStart()
             } else if vm.state == .vmPaused {
@@ -156,7 +159,7 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
             guard vm.state == .vmStopped else {
                 throw ScriptingError.notStopped
             }
-            try await data.delete(vm: vm, alsoRegistry: true)
+            try await data.delete(vm: box, alsoRegistry: true)
         }
     }
     
@@ -166,9 +169,9 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
             guard vm.state == .vmStopped else {
                 throw ScriptingError.notStopped
             }
-            let newVM = try await data.clone(vm: vm)
+            let newVM = try await data.clone(vm: box)
             if let properties = properties, let newConfiguration = properties["configuration"] as? [AnyHashable : Any] {
-                let wrapper = UTMScriptingConfigImpl(newVM.config.wrappedValue as! any UTMConfiguration)
+                let wrapper = UTMScriptingConfigImpl(newVM.config!)
                 try wrapper.updateConfiguration(from: newConfiguration)
                 try await data.save(vm: newVM)
             }

+ 8 - 0
UTM.xcodeproj/project.pbxproj

@@ -148,6 +148,9 @@
 		8471772827CD3CAB00D3A50B /* DetailedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471772727CD3CAB00D3A50B /* DetailedSection.swift */; };
 		8471772927CD3CAB00D3A50B /* DetailedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471772727CD3CAB00D3A50B /* DetailedSection.swift */; };
 		8471772A27CD3CAB00D3A50B /* DetailedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471772727CD3CAB00D3A50B /* DetailedSection.swift */; };
+		847BF9AA2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; };
+		847BF9AB2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; };
+		847BF9AC2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; };
 		84818C0C2898A07A009EDB67 /* AVFAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84818C0B2898A07A009EDB67 /* AVFAudio.framework */; };
 		84818C0D2898A07F009EDB67 /* AVFAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84818C0B2898A07A009EDB67 /* AVFAudio.framework */; };
 		848308D5278A1F2200E3E474 /* Virtualization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 848308D4278A1F2200E3E474 /* Virtualization.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
@@ -1319,6 +1322,7 @@
 		845F170C289CB3DE00944904 /* VMDisplayTerminal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayTerminal.swift; sourceTree = "<group>"; };
 		8471770527CC974F00D3A50B /* DefaultTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTextField.swift; sourceTree = "<group>"; };
 		8471772727CD3CAB00D3A50B /* DetailedSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailedSection.swift; sourceTree = "<group>"; };
+		847BF9A92A49C783000BD9AA /* VMData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMData.swift; sourceTree = "<group>"; };
 		84818C0B2898A07A009EDB67 /* AVFAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFAudio.framework; path = System/Library/Frameworks/AVFAudio.framework; sourceTree = SDKROOT; };
 		848308D4278A1F2200E3E474 /* Virtualization.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Virtualization.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.1.sdk/System/Library/Frameworks/Virtualization.framework; sourceTree = DEVELOPER_DIR; };
 		848A98AF286A0F74006F0550 /* UTMAppleConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleConfiguration.swift; sourceTree = "<group>"; };
@@ -2120,6 +2124,7 @@
 				CEB63A7924F469E300CAF323 /* UTMJailbreak.m */,
 				CEB63A7824F468BA00CAF323 /* UTMJailbreak.h */,
 				CE020BAA24AEE00000B44AB6 /* UTMLoggingSwift.swift */,
+				847BF9A92A49C783000BD9AA /* VMData.swift */,
 				CE2D955624AD4F980059923A /* Swift-Bridging-Header.h */,
 				CE550BD52259479D0063E575 /* Assets.xcassets */,
 				521F3EFB2414F73800130500 /* Localizable.strings */,
@@ -2824,6 +2829,7 @@
 				8443EFF22845641600B2E6E2 /* UTMQemuConfigurationDrive.swift in Sources */,
 				8443EFFA28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */,
 				CE772AAC25C8B0F600E4E379 /* ContentView.swift in Sources */,
+				847BF9AA2A49C783000BD9AA /* VMData.swift in Sources */,
 				CE2D927A24AD46670059923A /* UTMLegacyQemuConfiguration+System.m in Sources */,
 				843BF8302844853E0029D60D /* UTMQemuConfigurationNetwork.swift in Sources */,
 				CE2D927C24AD46670059923A /* UTMQemu.m in Sources */,
@@ -3141,6 +3147,7 @@
 				CE020BA424AEDC7C00B44AB6 /* UTMData.swift in Sources */,
 				CE25124929BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift in Sources */,
 				848A98C8287206AE006F0550 /* VMConfigAppleVirtualizationView.swift in Sources */,
+				847BF9AC2A49C783000BD9AA /* VMData.swift in Sources */,
 				CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */,
 				CE2D958824AD4F990059923A /* VMConfigPortForwardForm.swift in Sources */,
 				845F170D289CB3DE00944904 /* VMDisplayTerminal.swift in Sources */,
@@ -3281,6 +3288,7 @@
 				841E997628AA1191003C6CB6 /* UTMRegistry.swift in Sources */,
 				CEF0304F26A2AFBF00667B63 /* BigButtonStyle.swift in Sources */,
 				84018691288A73300050AC51 /* VMDisplayViewController.m in Sources */,
+				847BF9AB2A49C783000BD9AA /* VMData.swift in Sources */,
 				84909A8E27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,
 				CEA45EF4263519B5002FA97D /* VMConfigSoundView.swift in Sources */,
 				8432329928C3017F00CFBC97 /* GlobalFileImporter.swift in Sources */,