VMWizardState.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. //
  2. // Copyright © 2021 osy. All rights reserved.
  3. //
  4. // Licensed under the Apache License, Version 2.0 (the "License");
  5. // you may not use this file except in compliance with the License.
  6. // You may obtain a copy of the License at
  7. //
  8. // http://www.apache.org/licenses/LICENSE-2.0
  9. //
  10. // Unless required by applicable law or agreed to in writing, software
  11. // distributed under the License is distributed on an "AS IS" BASIS,
  12. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. // See the License for the specific language governing permissions and
  14. // limitations under the License.
  15. //
  16. import Foundation
  17. import SwiftUI
  18. #if canImport(Virtualization)
  19. import Virtualization
  20. #endif
  21. enum VMWizardPage: Int, Identifiable {
  22. var id: Int {
  23. return self.rawValue
  24. }
  25. case start
  26. case operatingSystem
  27. case macOSBoot
  28. case linuxBoot
  29. case windowsBoot
  30. case classicMacOSBoot
  31. case otherBoot
  32. case hardware
  33. case drives
  34. case sharing
  35. case summary
  36. }
  37. enum VMWizardOS: Identifiable {
  38. var id: Self { self }
  39. case Other
  40. case macOS
  41. case Linux
  42. case Windows
  43. case ClassicMacOS
  44. var name: LocalizedStringKey {
  45. switch self {
  46. case .Other: return "Other"
  47. case .macOS: return "macOS"
  48. case .Linux: return "Linux"
  49. case .Windows: return "Windows"
  50. case .ClassicMacOS: return "Mac OS"
  51. }
  52. }
  53. var defaultIconName: String? {
  54. switch self {
  55. case .Other: return nil
  56. case .macOS: return "mac"
  57. case .Linux: return "linux"
  58. case .Windows: return "windows"
  59. case .ClassicMacOS: return "macos"
  60. }
  61. }
  62. }
  63. enum VMBootDevice: Int, Identifiable {
  64. var id: Int {
  65. return self.rawValue
  66. }
  67. case none
  68. case cd
  69. case floppy
  70. case kernel
  71. case drive
  72. }
  73. struct AlertMessage: Identifiable {
  74. var message: String
  75. public var id: String {
  76. message
  77. }
  78. init(_ message: String) {
  79. self.message = message
  80. }
  81. }
  82. @MainActor class VMWizardState: ObservableObject {
  83. let bytesInMib = 1048576
  84. let bytesInGib = 1073741824
  85. @Published var slide: AnyTransition = .identity
  86. @Published var currentPage: VMWizardPage = .start
  87. @Published var pageHistory = [VMWizardPage]() {
  88. didSet {
  89. currentPage = pageHistory.last ?? .start
  90. }
  91. }
  92. @Published var nextPageBinding: Binding<VMWizardPage?> = .constant(nil)
  93. @Published var alertMessage: AlertMessage?
  94. @Published var isBusy: Bool = false
  95. @Published var systemBootUefi: Bool = true
  96. @Published var systemBootTpm: Bool = true
  97. @Published var isGuestToolsInstallRequested: Bool = false
  98. @Published var useVirtualization: Bool = false {
  99. didSet {
  100. if !useVirtualization {
  101. useAppleVirtualization = false
  102. }
  103. }
  104. }
  105. @Published var useAppleVirtualization: Bool = false {
  106. didSet {
  107. if #unavailable(macOS 13), useAppleVirtualization {
  108. bootDevice = .kernel
  109. }
  110. }
  111. }
  112. @Published var operatingSystem: VMWizardOS = .Other
  113. #if os(macOS) && arch(arm64)
  114. @Published var macPlatform: UTMAppleConfigurationMacPlatform?
  115. @Published var macRecoveryIpswURL: URL?
  116. @Published var macPlatformVersion: Int?
  117. var macIsLeastVentura: Bool {
  118. if let macPlatformVersion = macPlatformVersion {
  119. return macPlatformVersion >= 22
  120. } else {
  121. return false
  122. }
  123. }
  124. var macIsLeastSonoma: Bool {
  125. if let macPlatformVersion = macPlatformVersion {
  126. return macPlatformVersion >= 23
  127. } else {
  128. return false
  129. }
  130. }
  131. #endif
  132. @Published var legacyHardware: Bool = false
  133. @Published var bootDevice: VMBootDevice = .cd
  134. @Published var bootImageURL: URL?
  135. @Published var linuxKernelURL: URL?
  136. @Published var linuxInitialRamdiskURL: URL?
  137. @Published var linuxRootImageURL: URL?
  138. @Published var linuxBootArguments: String = ""
  139. @Published var linuxHasRosetta: Bool = false
  140. @Published var isWindows10OrHigher: Bool = true
  141. @Published var quadra800RomUrl: URL?
  142. @Published var systemArchitecture: QEMUArchitecture = .x86_64
  143. @Published var systemTarget: any QEMUTarget = QEMUTarget_x86_64.default
  144. #if os(macOS)
  145. @Published var systemMemoryMib: Int = 4096
  146. @Published var storageSizeGib: Int = 64
  147. #else
  148. @Published var systemMemoryMib: Int = 512
  149. @Published var storageSizeGib: Int = 8
  150. #endif
  151. @Published var systemCpuCount: Int = 0
  152. @Published var isDisplayEnabled: Bool = true
  153. @Published var isGLEnabled: Bool = false
  154. @Published var sharingDirectoryURL: URL?
  155. @Published var sharingReadOnly: Bool = false
  156. @Published var name: String?
  157. @Published var isOpenSettingsAfterCreation: Bool = false
  158. @Published var useNvmeAsDiskInterface = false
  159. @Published var machineProperties: String?
  160. /// SwiftUI BUG: on macOS 12, when VoiceOver is enabled and isBusy changes the disable state of a button being clicked,
  161. var isNeverDisabledWorkaround: Bool {
  162. #if os(macOS)
  163. if #available(macOS 12, *) {
  164. if #unavailable(macOS 13) {
  165. return false
  166. }
  167. }
  168. return true
  169. #else
  170. return true
  171. #endif
  172. }
  173. var hasNextButton: Bool {
  174. switch currentPage {
  175. case .start:
  176. return false
  177. case .operatingSystem:
  178. return false
  179. case .summary:
  180. return false
  181. default:
  182. return true
  183. }
  184. }
  185. #if os(macOS) && arch(arm64)
  186. var isPendingIPSWDownload: Bool {
  187. guard #available(macOS 12, *), useAppleVirtualization && operatingSystem == .macOS else {
  188. return false
  189. }
  190. guard let url = macRecoveryIpswURL else {
  191. return false
  192. }
  193. return !url.isFileURL
  194. }
  195. #else
  196. let isPendingIPSWDownload: Bool = false
  197. #endif
  198. var slideIn: AnyTransition {
  199. .asymmetric(insertion: .move(edge: .trailing), removal: .opacity)
  200. }
  201. var slideOut: AnyTransition {
  202. .asymmetric(insertion: .move(edge: .leading), removal: .opacity)
  203. }
  204. func next() {
  205. var nextPage = currentPage
  206. switch currentPage {
  207. case .start:
  208. nextPage = .operatingSystem
  209. case .operatingSystem:
  210. nextPage = .hardware
  211. case .hardware:
  212. guard systemMemoryMib > 0 else {
  213. alertMessage = AlertMessage(NSLocalizedString("Invalid RAM size specified.", comment: "VMWizardState"))
  214. return
  215. }
  216. switch operatingSystem {
  217. case .Other:
  218. nextPage = .otherBoot
  219. case .macOS:
  220. nextPage = .macOSBoot
  221. case .Linux:
  222. nextPage = .linuxBoot
  223. case .Windows:
  224. nextPage = .windowsBoot
  225. case .ClassicMacOS:
  226. nextPage = .classicMacOSBoot
  227. }
  228. case .otherBoot, .macOSBoot, .linuxBoot, .windowsBoot, .classicMacOSBoot:
  229. guard [.kernel, .none].contains(bootDevice) || bootImageURL != nil else {
  230. alertMessage = AlertMessage(NSLocalizedString("Please select a boot image.", comment: "VMWizardState"))
  231. return
  232. }
  233. if currentPage == .macOSBoot {
  234. #if os(macOS) && arch(arm64)
  235. if #available(macOS 12, *) {
  236. if macPlatform == nil || macRecoveryIpswURL == nil {
  237. fetchLatestPlatform()
  238. }
  239. }
  240. #endif
  241. }
  242. if currentPage == .linuxBoot {
  243. guard bootDevice != .kernel || linuxKernelURL != nil else {
  244. alertMessage = AlertMessage(NSLocalizedString("Please select a kernel file.", comment: "VMWizardState"))
  245. return
  246. }
  247. }
  248. if currentPage == .classicMacOSBoot {
  249. guard systemTarget.rawValue != QEMUTarget_m68k.q800.rawValue || quadra800RomUrl != nil else {
  250. alertMessage = AlertMessage(NSLocalizedString("Please select a ROM file.", comment: "VMWizardState"))
  251. return
  252. }
  253. }
  254. if bootDevice == .drive {
  255. nextPage = .sharing
  256. } else {
  257. nextPage = .drives
  258. }
  259. if operatingSystem == .Linux && linuxRootImageURL != nil {
  260. nextPage = .sharing
  261. if useAppleVirtualization {
  262. if #available(macOS 12, *) {
  263. } else {
  264. nextPage = .summary
  265. }
  266. }
  267. }
  268. case .drives:
  269. guard storageSizeGib > 0 else {
  270. alertMessage = AlertMessage(NSLocalizedString("Invalid drive size specified.", comment: "VMWizardState"))
  271. return
  272. }
  273. nextPage = .sharing
  274. if useAppleVirtualization {
  275. if #available(macOS 12, *) {
  276. if operatingSystem != .Linux {
  277. nextPage = .summary // only support linux currently
  278. }
  279. } else {
  280. nextPage = .summary
  281. }
  282. }
  283. case .sharing:
  284. nextPage = .summary
  285. case .summary:
  286. break
  287. }
  288. slide = slideIn
  289. withAnimation {
  290. pageHistory.append(nextPage)
  291. nextPageBinding.wrappedValue = nextPage
  292. nextPageBinding = .constant(nil)
  293. }
  294. }
  295. func back() {
  296. slide = slideOut
  297. withAnimation {
  298. _ = pageHistory.popLast()
  299. }
  300. }
  301. #if os(macOS)
  302. private func generateAppleConfig() throws -> UTMAppleConfiguration {
  303. let config = UTMAppleConfiguration()
  304. config.information.name = name!
  305. config.system.memorySize = systemMemoryMib
  306. config.system.cpuCount = systemCpuCount
  307. if bootDevice != .none, let bootImageURL = bootImageURL {
  308. config.drives.append(UTMAppleConfigurationDrive(existingURL: bootImageURL, isExternal: true))
  309. }
  310. var isSkipDiskCreate = false
  311. if let iconName = operatingSystem.defaultIconName {
  312. config.information.iconURL = UTMConfigurationInfo.builtinIcon(named: iconName)
  313. }
  314. switch operatingSystem {
  315. case .Other, .ClassicMacOS, .Windows:
  316. break
  317. case .macOS:
  318. #if os(macOS) && arch(arm64)
  319. if #available(macOS 12, *) {
  320. config.system.boot = try! UTMAppleConfigurationBoot(for: .macOS)
  321. config.system.boot.macRecoveryIpswURL = macRecoveryIpswURL
  322. config.system.macPlatform = macPlatform
  323. }
  324. #endif
  325. case .Linux:
  326. #if os(macOS)
  327. if bootDevice == .kernel {
  328. var bootloader = try UTMAppleConfigurationBoot(for: .linux, linuxKernelURL: linuxKernelURL!)
  329. bootloader.linuxInitialRamdiskURL = linuxInitialRamdiskURL
  330. bootloader.linuxCommandLine = linuxBootArguments
  331. config.system.boot = bootloader
  332. if let linuxRootImageURL = linuxRootImageURL {
  333. config.drives.append(UTMAppleConfigurationDrive(existingURL: linuxRootImageURL))
  334. isSkipDiskCreate = true
  335. }
  336. } else {
  337. config.system.boot = try UTMAppleConfigurationBoot(for: .linux)
  338. }
  339. config.system.genericPlatform = UTMAppleConfigurationGenericPlatform()
  340. config.virtualization.hasRosetta = linuxHasRosetta
  341. #endif
  342. }
  343. if !isSkipDiskCreate {
  344. var newDisk = UTMAppleConfigurationDrive(newSize: storageSizeGib * bytesInGib / bytesInMib)
  345. if #available(macOS 14, *), useNvmeAsDiskInterface {
  346. newDisk.isNvme = true
  347. }
  348. if #available(macOS 26, *), UTMASIFImage.sharedInstance() != nil {
  349. newDisk.isASIF = true
  350. }
  351. config.drives.append(newDisk)
  352. }
  353. if #available(macOS 12, *), let sharingDirectoryURL = sharingDirectoryURL {
  354. config.sharedDirectories = [UTMAppleConfigurationSharedDirectory(directoryURL: sharingDirectoryURL, isReadOnly: sharingReadOnly)]
  355. }
  356. // some meaningful defaults
  357. if #available(macOS 12, *) {
  358. let isMac = operatingSystem == .macOS
  359. var hasDisplay = isMac
  360. if #available(macOS 13, *) {
  361. hasDisplay = hasDisplay || (operatingSystem == .Linux)
  362. }
  363. if hasDisplay {
  364. config.displays = [UTMAppleConfigurationDisplay(width: 1920, height: 1200)]
  365. config.virtualization.hasAudio = true
  366. config.virtualization.keyboard = .generic
  367. config.virtualization.pointer = .mouse
  368. }
  369. #if arch(arm64)
  370. if isMac && macIsLeastVentura {
  371. config.virtualization.pointer = .trackpad
  372. }
  373. if isMac && macIsLeastSonoma {
  374. config.virtualization.keyboard = .mac
  375. }
  376. #endif
  377. }
  378. config.virtualization.hasBalloon = true
  379. config.virtualization.hasEntropy = true
  380. config.networks = [UTMAppleConfigurationNetwork()]
  381. if operatingSystem == .Linux && bootDevice == .kernel {
  382. config.serials = [UTMAppleConfigurationSerial()]
  383. }
  384. if #available(macOS 13, *) {
  385. config.virtualization.hasClipboardSharing = true
  386. }
  387. return config
  388. }
  389. #if arch(arm64)
  390. @available(macOS 12, *)
  391. private func fetchLatestPlatform() {
  392. VZMacOSRestoreImage.fetchLatestSupported { result in
  393. switch result {
  394. case .success(let restoreImage):
  395. DispatchQueue.main.async {
  396. if let hardwareModel = restoreImage.mostFeaturefulSupportedConfiguration?.hardwareModel {
  397. self.macPlatform = UTMAppleConfigurationMacPlatform(newHardware: hardwareModel)
  398. self.macRecoveryIpswURL = restoreImage.url
  399. self.macPlatformVersion = restoreImage.buildVersion.integerPrefix()
  400. } else {
  401. self.alertMessage = AlertMessage(NSLocalizedString("Failed to get latest macOS version from Apple.", comment: "VMWizardState"))
  402. }
  403. }
  404. case .failure(let error):
  405. DispatchQueue.main.async {
  406. self.alertMessage = AlertMessage(error.localizedDescription)
  407. }
  408. }
  409. }
  410. }
  411. #endif
  412. #endif
  413. private func generateQemuConfig() throws -> UTMQemuConfiguration {
  414. let isClassicMacM68K = systemArchitecture == .m68k && systemTarget.rawValue == QEMUTarget_m68k.q800.rawValue
  415. let isClassicMacPPC = [.ppc, .ppc64].contains(systemArchitecture) && systemTarget.rawValue == QEMUTarget_ppc.mac99.rawValue
  416. let config = UTMQemuConfiguration()
  417. config.information.name = name!
  418. config.system.architecture = systemArchitecture
  419. config.system.target = systemTarget
  420. config.reset(forArchitecture: systemArchitecture, target: systemTarget)
  421. config.system.memorySize = systemMemoryMib
  422. config.system.cpuCount = systemCpuCount
  423. config.qemu.hasHypervisor = useVirtualization
  424. config.sharing.isDirectoryShareReadOnly = sharingReadOnly
  425. if let sharingDirectoryURL = sharingDirectoryURL {
  426. config.sharing.directoryShareUrl = sharingDirectoryURL
  427. }
  428. if config.sharing.directoryShareMode != .none && operatingSystem == .Linux {
  429. // change default sharing to virtfs if linux
  430. config.sharing.directoryShareMode = .virtfs
  431. }
  432. if operatingSystem == .Windows || operatingSystem == .Other {
  433. // only change UEFI settings for Windows or Other
  434. config.qemu.hasUefiBoot = systemBootUefi
  435. config.qemu.hasTPMDevice = operatingSystem == .Windows && systemBootTpm
  436. config.qemu.hasPreloadedSecureBootKeys = config.qemu.hasTPMDevice
  437. } else if legacyHardware {
  438. config.qemu.hasUefiBoot = false
  439. config.qemu.hasTPMDevice = false
  440. }
  441. if operatingSystem == .Linux && config.displays.first != nil {
  442. // change default display to virtio-gpu if supported
  443. let newCard = isGLEnabled ? "virtio-gpu-gl-pci" : "virtio-gpu-pci"
  444. let allCards = systemArchitecture.displayDeviceType.allRawValues
  445. if allCards.contains(where: { $0 == newCard }) {
  446. config.displays[0].hardware = AnyQEMUConstant(rawValue: newCard)!
  447. }
  448. } else if isGLEnabled || operatingSystem == .Windows, let displayCard = config.displays.first?.hardware {
  449. let newCard = displayCard.rawValue + "-gl"
  450. let allCards = systemArchitecture.displayDeviceType.allRawValues
  451. if allCards.contains(where: { $0 == newCard }) {
  452. config.displays[0].hardware = AnyQEMUConstant(rawValue: newCard)!
  453. }
  454. }
  455. if operatingSystem == .Linux && !isDisplayEnabled {
  456. config.displays = []
  457. let newSerial = UTMQemuConfigurationSerial(forArchitecture: systemArchitecture, target: systemTarget)!
  458. config.serials = [newSerial]
  459. }
  460. let mainDriveInterface: QEMUDriveInterface
  461. if systemArchitecture == .aarch64 && operatingSystem == .Windows {
  462. mainDriveInterface = .nvme
  463. } else {
  464. mainDriveInterface = UTMQemuConfigurationDrive.defaultInterface(forArchitecture: systemArchitecture, target: systemTarget, imageType: .disk)
  465. }
  466. if bootDevice != .none && bootImageURL != nil {
  467. var bootDrive = UTMQemuConfigurationDrive(forArchitecture: systemArchitecture, target: systemTarget, isExternal: bootDevice != .drive)
  468. if bootDevice == .floppy {
  469. bootDrive.interface = .floppy
  470. } else if bootDevice == .drive {
  471. bootDrive.interface = mainDriveInterface
  472. }
  473. if isClassicMacM68K {
  474. //bootDrive.interfaceLocation = [3, 0]
  475. } else if isClassicMacPPC {
  476. //bootDrive.interfaceLocation = [0, 1]
  477. }
  478. bootDrive.imageURL = bootImageURL
  479. config.drives.append(bootDrive)
  480. }
  481. if let iconName = operatingSystem.defaultIconName {
  482. config.information.iconURL = UTMConfigurationInfo.builtinIcon(named: iconName)
  483. }
  484. switch operatingSystem {
  485. case .Other:
  486. break
  487. case .macOS:
  488. throw NSLocalizedString("macOS is not supported with QEMU.", comment: "VMWizardState")
  489. case .Linux:
  490. if bootDevice == .kernel {
  491. var kernel = UTMQemuConfigurationDrive()
  492. kernel.imageURL = linuxKernelURL
  493. kernel.imageType = .linuxKernel
  494. kernel.isRawImage = true
  495. config.drives.append(kernel)
  496. if let linuxInitialRamdiskURL = linuxInitialRamdiskURL {
  497. var initrd = UTMQemuConfigurationDrive()
  498. initrd.imageURL = linuxInitialRamdiskURL
  499. initrd.imageType = .linuxInitrd
  500. initrd.isRawImage = true
  501. config.drives.append(initrd)
  502. }
  503. if let linuxRootImageURL = linuxRootImageURL {
  504. var rootImage = UTMQemuConfigurationDrive()
  505. rootImage.imageURL = linuxRootImageURL
  506. rootImage.imageType = .disk
  507. rootImage.interface = mainDriveInterface
  508. config.drives.append(rootImage)
  509. }
  510. if linuxBootArguments.count > 0 {
  511. config.qemu.additionalArguments.append(QEMUArgument("-append"))
  512. config.qemu.additionalArguments.append(QEMUArgument(linuxBootArguments))
  513. }
  514. }
  515. case .Windows:
  516. config.qemu.hasRTCLocalTime = true
  517. case .ClassicMacOS:
  518. if systemArchitecture == .ppc || systemArchitecture == .ppc64 {
  519. config.qemu.machinePropertyOverride = machineProperties
  520. }
  521. if systemArchitecture == .m68k {
  522. var pramDrive = UTMQemuConfigurationDrive()
  523. pramDrive.sizeMib = 1
  524. pramDrive.imageType = .disk
  525. pramDrive.interface = .mtd
  526. config.drives.append(pramDrive)
  527. if let quadra800RomUrl = quadra800RomUrl {
  528. var bios = UTMQemuConfigurationDrive()
  529. bios.imageURL = quadra800RomUrl
  530. bios.imageType = .bios
  531. bios.isRawImage = true
  532. config.drives.append(bios)
  533. }
  534. }
  535. }
  536. if bootDevice != .drive {
  537. var diskImage = UTMQemuConfigurationDrive()
  538. diskImage.sizeMib = storageSizeGib * bytesInGib / bytesInMib
  539. diskImage.imageType = .disk
  540. diskImage.interface = mainDriveInterface
  541. if isClassicMacM68K {
  542. //diskImage.interfaceLocation = [0, 0]
  543. } else if isClassicMacPPC {
  544. //diskImage.interfaceLocation = [0, 0]
  545. }
  546. if isClassicMacPPC || isClassicMacM68K {
  547. config.drives.insert(diskImage, at: 0)
  548. } else {
  549. config.drives.append(diskImage)
  550. }
  551. if (operatingSystem == .Windows && isGuestToolsInstallRequested) ||
  552. (legacyHardware && bootDevice == .floppy) {
  553. // extra CD drive for guest tools OR first CD drive for floppy boot systems
  554. let toolsDiskDrive = UTMQemuConfigurationDrive(forArchitecture: systemArchitecture, target: systemTarget, isExternal: true)
  555. config.drives.append(toolsDiskDrive)
  556. }
  557. }
  558. if legacyHardware && operatingSystem == .Windows {
  559. config.qemu.hasPS2Controller = true
  560. }
  561. if legacyHardware && systemArchitecture.hasUsbSupport && systemTarget.hasUsbSupport {
  562. config.input.usbBusSupport = .usb2_0
  563. }
  564. return config
  565. }
  566. func generateConfig() throws -> any UTMConfiguration {
  567. guard name != nil else {
  568. throw VMWizardError.nameEmpty
  569. }
  570. if useVirtualization && useAppleVirtualization {
  571. #if os(macOS)
  572. return try generateAppleConfig()
  573. #else
  574. throw NSLocalizedString("Unavailable for this platform.", comment: "VMWizardState")
  575. #endif
  576. } else {
  577. return try generateQemuConfig()
  578. }
  579. }
  580. /// Execute a task with spinning progress indicator (Swift concurrency version)
  581. /// - Parameter work: Function to execute
  582. func busyWorkAsync(_ work: @escaping @Sendable () async throws -> Void) {
  583. Task.detached(priority: .userInitiated) {
  584. await MainActor.run { self.isBusy = true }
  585. do {
  586. try await work()
  587. } catch {
  588. logger.error("\(error)")
  589. await MainActor.run { self.alertMessage = AlertMessage(error.localizedDescription) }
  590. }
  591. await MainActor.run { self.isBusy = false }
  592. }
  593. }
  594. }
  595. // MARK: - Warnings for common mistakes
  596. extension VMWizardState {
  597. nonisolated func confusedUserCheck() {
  598. Task { @MainActor in
  599. do {
  600. try confusedUserCheckBootImage()
  601. } catch {
  602. self.alertMessage = AlertMessage(error.localizedDescription)
  603. }
  604. }
  605. }
  606. private func confusedUserCheckBootImage() throws {
  607. guard let path = bootImageURL?.path.lowercased() else {
  608. return
  609. }
  610. if systemArchitecture == .aarch64 {
  611. if path.contains("x64") {
  612. throw VMWizardError.confusedArchitectureWarning("x64", systemArchitecture, "a64")
  613. }
  614. if path.contains("amd64") {
  615. throw VMWizardError.confusedArchitectureWarning("amd64", systemArchitecture, "arm64")
  616. }
  617. if path.contains("x86_64") {
  618. throw VMWizardError.confusedArchitectureWarning("x86_64", systemArchitecture, "arm64")
  619. }
  620. }
  621. if systemArchitecture == .x86_64 {
  622. if path.contains("arm64") {
  623. throw VMWizardError.confusedArchitectureWarning("arm64", systemArchitecture, "amd64")
  624. }
  625. }
  626. }
  627. }
  628. enum VMWizardError: Error {
  629. case confusedArchitectureWarning(String, QEMUArchitecture, String)
  630. case nameEmpty
  631. }
  632. extension VMWizardError: LocalizedError {
  633. var errorDescription: String? {
  634. switch self {
  635. case .confusedArchitectureWarning(let pattern, let architecture, let expected): return String.localizedStringWithFormat(NSLocalizedString("The selected boot image contains the word '%@' but the guest architecture is '%@'. Please ensure you have selected an image that is compatible with '%@'.", comment: "VMWizardState"), pattern, architecture.prettyValue, expected)
  636. case .nameEmpty: return NSLocalizedString("Name cannot be empty.", comment: "VMWizardState")
  637. }
  638. }
  639. }