data: refactor UTMVirtualMachine to VMData for use in Views

This replaces the functionality of UTMWrappedVirtualMachine and moves some of
the presentation logic from UTMVirtualMachine.
This commit is contained in:
osy 2023-06-26 23:28:18 -05:00
parent aa203e18b7
commit d498239b38
28 changed files with 804 additions and 320 deletions

View File

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

View File

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

View File

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

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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: "/"))))
}
}

View File

@ -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: "/"))))
}
}

View File

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

View File

@ -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)
}
}
}

View File

@ -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()

View File

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

View File

@ -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()

View File

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

View File

@ -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)
}
}

View File

@ -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")
try copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
guard let wrapped = UTMVirtualMachine(url: newPath) else {
throw UTMDataError.cloneFailed
}
await newVM.changeUuid(to: UUID(), name: newName)
try await newVM.saveUTM()
var index = await virtualMachines.firstIndex(of: vm)
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")
guard let wrapped = UTMVirtualMachine(url: url) else {
throw UTMDataError.shortcutCreationFailed
}
await MainActor.run {
newVM.isShortcut = true
}
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
Platform/VMData.swift Normal file
View File

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

View File

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

View File

@ -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)
}
}

View File

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

View File

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

View File

@ -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")

View File

@ -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()
}

View File

@ -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…")

View File

@ -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)
}
}
}
}

View File

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

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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 */,