UTMData.swift 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463
  1. //
  2. // Copyright © 2020 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 os(macOS)
  19. import AppKit
  20. #else
  21. import UIKit
  22. #endif
  23. #if canImport(AltKit) && WITH_JIT
  24. import AltKit
  25. #endif
  26. #if WITH_SERVER
  27. import Combine
  28. #endif
  29. import SwiftCopyfile
  30. #if WITH_REMOTE
  31. import CocoaSpiceNoUsb
  32. typealias ConcreteVirtualMachine = UTMRemoteSpiceVirtualMachine
  33. #else
  34. typealias ConcreteVirtualMachine = UTMQemuVirtualMachine
  35. #endif
  36. enum AlertItem: Identifiable {
  37. case message(String)
  38. case localizedMessage(LocalizedStringKey)
  39. case downloadUrl(URL)
  40. var id: Int {
  41. switch self {
  42. case .downloadUrl(let url):
  43. return url.hashValue
  44. case .message(let message):
  45. return message.hashValue
  46. case .localizedMessage(let message):
  47. return message.localizedString.hashValue
  48. }
  49. }
  50. }
  51. @MainActor class UTMData: ObservableObject {
  52. /// Sandbox location for storing .utm bundles
  53. nonisolated static var defaultStorageUrl: URL {
  54. FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  55. }
  56. /// View: show VM settings
  57. @Published var showSettingsModal: Bool
  58. /// View: show new VM wizard
  59. @Published var showNewVMSheet: Bool
  60. /// View: show an alert message
  61. @Published var alertItem: AlertItem?
  62. /// View: show busy spinner
  63. @Published var busy: Bool
  64. /// View: show a percent progress in the busy spinner
  65. @Published var busyProgress: Float?
  66. /// View: currently selected VM
  67. @Published var selectedVM: VMData?
  68. /// View: all VMs listed, we save a bookmark to each when array is modified
  69. @Published private(set) var virtualMachines: [VMData] {
  70. didSet {
  71. listSaveToDefaults()
  72. }
  73. }
  74. /// View: all pending VMs listed (ZIP and IPSW downloads)
  75. @Published private(set) var pendingVMs: [UTMPendingVirtualMachine]
  76. #if os(macOS)
  77. /// View controller for every VM currently active
  78. var vmWindows: [VMData: Any] = [:]
  79. #else
  80. /// View controller for currently active VM
  81. var vmVC: Any?
  82. /// View state for active VM primary display
  83. @State var vmPrimaryWindowState: VMWindowState?
  84. #endif
  85. /// Shortcut for accessing FileManager.default
  86. nonisolated private var fileManager: FileManager {
  87. FileManager.default
  88. }
  89. /// Shortcut for accessing storage URL from instance
  90. nonisolated private var documentsURL: URL {
  91. UTMData.defaultStorageUrl
  92. }
  93. #if WITH_SERVER
  94. /// Remote access server
  95. private(set) var remoteServer: UTMRemoteServer!
  96. /// Listeners for remote access
  97. private var remoteChangeListeners: [VMData: Set<AnyCancellable>] = [:]
  98. /// Listener for list changes
  99. private var listChangedListener: AnyCancellable?
  100. #endif
  101. /// Queue to run `busyWork` tasks
  102. private var busyQueue: DispatchQueue
  103. init() {
  104. self.busyQueue = DispatchQueue(label: "UTM Busy Queue", qos: .userInitiated)
  105. self.showSettingsModal = false
  106. self.showNewVMSheet = false
  107. self.busy = false
  108. self.virtualMachines = []
  109. self.pendingVMs = []
  110. self.selectedVM = nil
  111. #if WITH_SERVER
  112. self.remoteServer = UTMRemoteServer(data: self)
  113. beginObservingChanges()
  114. #endif
  115. listLoadFromDefaults()
  116. }
  117. // MARK: - VM listing
  118. /// Re-loads UTM bundles from default path
  119. ///
  120. /// This removes stale entries (deleted/not accessible) and duplicate entries
  121. func listRefresh() async {
  122. // create Documents directory if it doesn't exist
  123. if !fileManager.fileExists(atPath: Self.defaultStorageUrl.path) {
  124. try? fileManager.createDirectory(at: Self.defaultStorageUrl, withIntermediateDirectories: false)
  125. }
  126. // wrap stale VMs
  127. var list = virtualMachines
  128. for i in list.indices.reversed() {
  129. let vm = list[i]
  130. if let registryEntry = vm.registryEntry, !fileManager.fileExists(atPath: registryEntry.package.path) {
  131. list[i] = VMData(from: registryEntry)
  132. }
  133. }
  134. // now look for and add new VMs in default storage
  135. do {
  136. let files = try fileManager.contentsOfDirectory(at: UTMData.defaultStorageUrl, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles)
  137. let newFiles = files.filter { newFile in
  138. !list.contains { existingVM in
  139. existingVM.pathUrl.standardizedFileURL == newFile.standardizedFileURL
  140. }
  141. }
  142. for file in newFiles {
  143. guard try file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false else {
  144. continue
  145. }
  146. guard ConcreteVirtualMachine.isVirtualMachine(url: file) else {
  147. continue
  148. }
  149. await Task.yield()
  150. if let vm = try? VMData(url: file) {
  151. if uuidHasCollision(with: vm, in: list) {
  152. if let index = list.firstIndex(where: { !$0.isLoaded && $0.id == vm.id }) {
  153. // we have a stale VM with the same UUID, so we replace that entry with this one
  154. list[index] = vm
  155. // update the registry with the new bookmark
  156. try? await vm.wrapped!.updateRegistryFromConfig()
  157. continue
  158. } else {
  159. // duplicate is not stale so we need a new UUID
  160. uuidRegenerate(for: vm)
  161. }
  162. }
  163. list.insert(vm, at: 0)
  164. } else {
  165. logger.error("Failed to create object for \(file)")
  166. }
  167. }
  168. } catch {
  169. logger.error("\(error.localizedDescription)")
  170. }
  171. // replace the VM list with our new one
  172. if virtualMachines != list {
  173. listReplace(with: list)
  174. }
  175. // prune the registry
  176. let uuids = list.compactMap({ $0.registryEntry?.uuid.uuidString })
  177. UTMRegistry.shared.prune(exceptFor: Set(uuids))
  178. }
  179. /// Load VM list (and order) from persistent storage
  180. fileprivate func listLoadFromDefaults() {
  181. let defaults = UserDefaults.standard
  182. guard defaults.object(forKey: "VMList") == nil else {
  183. listLegacyLoadFromDefaults()
  184. // fix collisions
  185. for vm in virtualMachines {
  186. if uuidHasCollision(with: vm) {
  187. uuidRegenerate(for: vm)
  188. }
  189. }
  190. // delete legacy
  191. defaults.removeObject(forKey: "VMList")
  192. return
  193. }
  194. // registry entry list
  195. guard let list = defaults.stringArray(forKey: "VMEntryList") else {
  196. return
  197. }
  198. let virtualMachines: [VMData] = list.uniqued().compactMap { uuidString in
  199. guard let entry = UTMRegistry.shared.entry(for: uuidString) else {
  200. return nil
  201. }
  202. let vm = VMData(from: entry)
  203. do {
  204. try vm.load()
  205. } catch {
  206. logger.error("Error loading '\(entry.uuid)': \(error)")
  207. }
  208. return vm
  209. }
  210. listReplace(with: virtualMachines)
  211. }
  212. /// Load VM list (and order) from persistent storage (legacy)
  213. private func listLegacyLoadFromDefaults() {
  214. let defaults = UserDefaults.standard
  215. // legacy path list
  216. if let files = defaults.array(forKey: "VMList") as? [String] {
  217. let virtualMachines = files.uniqued().compactMap({ file in
  218. let url = documentsURL.appendingPathComponent(file, isDirectory: true)
  219. if let vm = try? VMData(url: url) {
  220. return vm
  221. } else {
  222. return nil
  223. }
  224. })
  225. listReplace(with: virtualMachines)
  226. }
  227. // bookmark list
  228. if let list = defaults.array(forKey: "VMList") {
  229. let virtualMachines = list.compactMap { item in
  230. let vm: VMData?
  231. if let bookmark = item as? Data {
  232. vm = VMData(bookmark: bookmark)
  233. } else if let dict = item as? [String: Any] {
  234. vm = VMData(from: dict)
  235. } else {
  236. vm = nil
  237. }
  238. try? vm?.load()
  239. return vm
  240. }
  241. listReplace(with: virtualMachines)
  242. }
  243. }
  244. /// Save VM list (and order) to persistent storage
  245. private func listSaveToDefaults() {
  246. let defaults = UserDefaults.standard
  247. let wrappedVMs = virtualMachines.map { $0.id.uuidString }
  248. defaults.set(wrappedVMs, forKey: "VMEntryList")
  249. }
  250. /// Replace current VM list with a new list
  251. /// - Parameter vms: List to replace with
  252. fileprivate func listReplace(with vms: [VMData]) {
  253. virtualMachines.forEach({ endObservingChanges(for: $0) })
  254. virtualMachines = vms
  255. vms.forEach({ beginObservingChanges(for: $0) })
  256. if let vm = selectedVM, !vms.contains(where: { $0 == vm }) {
  257. selectedVM = nil
  258. }
  259. }
  260. /// Add VM to list
  261. /// - Parameter vm: VM to add
  262. /// - Parameter at: Optional index to add to, otherwise will be added to the end
  263. private func listAdd(vm: VMData, at index: Int? = nil) {
  264. if uuidHasCollision(with: vm) {
  265. uuidRegenerate(for: vm)
  266. }
  267. if let index = index {
  268. virtualMachines.insert(vm, at: index)
  269. } else {
  270. virtualMachines.append(vm)
  271. }
  272. beginObservingChanges(for: vm)
  273. }
  274. /// Select VM in list
  275. /// - Parameter vm: VM to select
  276. public func listSelect(vm: VMData) {
  277. selectedVM = vm
  278. }
  279. /// Remove a VM from list
  280. /// - Parameter vm: VM to remove
  281. /// - Returns: Index of item removed or nil if already removed
  282. @discardableResult public func listRemove(vm: VMData) -> Int? {
  283. let index = virtualMachines.firstIndex(of: vm)
  284. endObservingChanges(for: vm)
  285. if let index = index {
  286. virtualMachines.remove(at: index)
  287. }
  288. if vm == selectedVM {
  289. selectedVM = nil
  290. }
  291. vm.isDeleted = true // alert views to update
  292. return index
  293. }
  294. /// Add pending VM to list
  295. /// - Parameter pendingVM: Pending VM to add
  296. /// - Parameter at: Optional index to add to, otherwise will be added to the end
  297. private func listAdd(pendingVM: UTMPendingVirtualMachine, at index: Int? = nil) {
  298. if let index = index {
  299. pendingVMs.insert(pendingVM, at: index)
  300. } else {
  301. pendingVMs.append(pendingVM)
  302. }
  303. }
  304. /// Remove pending VM from list
  305. /// - Parameter pendingVM: Pending VM to remove
  306. /// - Returns: Index of item removed or nil if already removed
  307. @discardableResult private func listRemove(pendingVM: UTMPendingVirtualMachine) -> Int? {
  308. let index = pendingVMs.firstIndex(where: { $0.id == pendingVM.id })
  309. if let index = index {
  310. pendingVMs.remove(at: index)
  311. }
  312. return index
  313. }
  314. /// Move items in VM list
  315. /// - Parameters:
  316. /// - fromOffsets: Offsets from move from
  317. /// - toOffset: Offsets to move to
  318. func listMove(fromOffsets: IndexSet, toOffset: Int) {
  319. virtualMachines.move(fromOffsets: fromOffsets, toOffset: toOffset)
  320. }
  321. // MARK: - New name
  322. /// Generate a unique VM name
  323. /// - Parameter base: Base name
  324. /// - Returns: Unique name for a non-existing item in the default storage path
  325. nonisolated func newDefaultVMName(base: String = NSLocalizedString("Virtual Machine", comment: "UTMData")) -> String {
  326. let nameForId = { (i: Int) in i <= 1 ? base : "\(base) \(i)" }
  327. for i in 1..<1000 {
  328. let name = nameForId(i)
  329. let file = ConcreteVirtualMachine.virtualMachinePath(for: name, in: documentsURL)
  330. if !fileManager.fileExists(atPath: file.path) {
  331. return name
  332. }
  333. }
  334. return ProcessInfo.processInfo.globallyUniqueString
  335. }
  336. /// Generate a filename for an imported file, avoiding duplicate names
  337. /// - Parameters:
  338. /// - sourceUrl: Source image where name will come from
  339. /// - destUrl: Destination directory where duplicates will be checked
  340. /// - withExtension: Optionally change the file extension
  341. /// - Returns: Unique filename that is not used in the destUrl
  342. nonisolated static func newImage(from sourceUrl: URL, to destUrl: URL, withExtension: String? = nil) -> URL {
  343. let name = sourceUrl.deletingPathExtension().lastPathComponent
  344. let ext = withExtension ?? sourceUrl.pathExtension
  345. let strFromInt = { (i: Int) in i == 1 ? "" : "-\(i)" }
  346. for i in 1..<1000 {
  347. let attempt = "\(name)\(strFromInt(i))"
  348. let attemptUrl = destUrl.appendingPathComponent(attempt).appendingPathExtension(ext)
  349. if !FileManager.default.fileExists(atPath: attemptUrl.path) {
  350. return attemptUrl
  351. }
  352. }
  353. repeat {
  354. let attempt = UUID().uuidString
  355. let attemptUrl = destUrl.appendingPathComponent(attempt).appendingPathExtension(ext)
  356. if !FileManager.default.fileExists(atPath: attemptUrl.path) {
  357. return attemptUrl
  358. }
  359. } while true
  360. }
  361. // MARK: - Other view states
  362. private func setBusyIndicator(_ busy: Bool) {
  363. self.busy = busy
  364. }
  365. func showErrorAlert(message: String) {
  366. alertItem = .message(message)
  367. }
  368. func showLocalizedErrorAlert(_ message: LocalizedStringKey) {
  369. alertItem = .localizedMessage(message)
  370. }
  371. func newVM() {
  372. showSettingsModal = false
  373. showNewVMSheet = true
  374. }
  375. func showSettingsForCurrentVM() {
  376. #if os(iOS) || os(visionOS)
  377. // SwiftUI bug: cannot show modal at the same time as changing selected VM or it breaks
  378. DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
  379. self.showSettingsModal = true
  380. }
  381. #else
  382. showSettingsModal = true
  383. #endif
  384. }
  385. // MARK: - VM operations
  386. /// Save an existing VM to disk
  387. /// - Parameter vm: VM to save
  388. func save(vm: VMData) async throws {
  389. do {
  390. try await vm.save()
  391. #if WITH_SERVER
  392. if let qemuConfig = vm.config as? UTMQemuConfiguration {
  393. await remoteServer.broadcast { remote in
  394. try await remote.qemuConfigurationHasChanged(id: vm.id, configuration: qemuConfig)
  395. }
  396. }
  397. #endif
  398. } catch {
  399. // refresh the VM object as it is now stale
  400. let origError = error
  401. do {
  402. try discardChanges(for: vm)
  403. } catch {
  404. // if we can't discard changes, recreate the VM from scratch
  405. let path = vm.pathUrl
  406. guard let newVM = try? VMData(url: path) else {
  407. logger.debug("Cannot create new object for \(path.path)")
  408. throw origError
  409. }
  410. let index = listRemove(vm: vm)
  411. listAdd(vm: newVM, at: index)
  412. listSelect(vm: newVM)
  413. }
  414. throw origError
  415. }
  416. }
  417. /// Discard changes to VM configuration
  418. /// - Parameter vm: VM configuration to discard
  419. func discardChanges(for vm: VMData) throws {
  420. if let wrapped = vm.wrapped {
  421. try wrapped.reload(from: nil)
  422. if uuidHasCollision(with: vm) {
  423. wrapped.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
  424. }
  425. }
  426. }
  427. /// Save a new VM to disk
  428. /// - Parameters:
  429. /// - config: New VM configuration
  430. func create<Config: UTMConfiguration>(config: Config) async throws -> VMData {
  431. guard !virtualMachines.contains(where: { !$0.isShortcut && $0.config?.information.name == config.information.name }) else {
  432. throw UTMDataError.virtualMachineAlreadyExists
  433. }
  434. let vm = try VMData(creatingFromConfig: config, destinationUrl: Self.defaultStorageUrl)
  435. do {
  436. try await save(vm: vm)
  437. } catch {
  438. if isDirectoryEmpty(vm.pathUrl) {
  439. try? fileManager.removeItem(at: vm.pathUrl)
  440. }
  441. throw error
  442. }
  443. listAdd(vm: vm)
  444. listSelect(vm: vm)
  445. return vm
  446. }
  447. /// Delete a VM from disk
  448. /// - Parameter vm: VM to delete
  449. /// - Returns: Index of item removed in VM list or nil if not in list
  450. @discardableResult func delete(vm: VMData, alsoRegistry: Bool = true) async throws -> Int? {
  451. if vm.isLoaded {
  452. try fileManager.removeItem(at: vm.pathUrl)
  453. }
  454. // close any open window
  455. close(vm: vm)
  456. if alsoRegistry, let registryEntry = vm.registryEntry {
  457. UTMRegistry.shared.remove(entry: registryEntry)
  458. }
  459. return listRemove(vm: vm)
  460. }
  461. /// Save a copy of the VM and all data to default storage location
  462. /// - Parameter vm: VM to clone
  463. /// - Returns: The new VM
  464. @discardableResult func clone(vm: VMData) async throws -> VMData {
  465. let newName: String = newDefaultVMName(base: vm.detailsTitleLabel)
  466. let newPath = ConcreteVirtualMachine.virtualMachinePath(for: newName, in: documentsURL)
  467. let isRegenerateMACOnClone = UserDefaults.standard.bool(forKey: "IsRegenerateMACOnClone")
  468. try await copyItemWithCopyfile(at: vm.pathUrl, to: newPath)
  469. guard let newVM = try? VMData(url: newPath) else {
  470. throw UTMDataError.cloneFailed
  471. }
  472. newVM.wrapped!.changeUuid(to: UUID(), name: newName, copyingEntry: nil)
  473. if isRegenerateMACOnClone {
  474. if let config = newVM.wrapped!.config as? UTMQemuConfiguration {
  475. for i in config.networks.indices {
  476. config.networks[i].macAddress = UTMQemuConfigurationNetwork.randomMacAddress()
  477. }
  478. }
  479. }
  480. try await newVM.save()
  481. var index = virtualMachines.firstIndex(of: vm)
  482. if index != nil {
  483. index! += 1
  484. }
  485. listAdd(vm: newVM, at: index)
  486. listSelect(vm: newVM)
  487. return newVM
  488. }
  489. /// Save a copy of the VM and all data to arbitary location
  490. /// - Parameters:
  491. /// - vm: VM to copy
  492. /// - url: Location to copy to (must be writable)
  493. func export(vm: VMData, to url: URL) async throws {
  494. let sourceUrl = vm.pathUrl
  495. if fileManager.fileExists(atPath: url.path) {
  496. try fileManager.removeItem(at: url)
  497. }
  498. try await copyItemWithCopyfile(at: sourceUrl, to: url)
  499. }
  500. /// Save a copy of the VM and all data to arbitary location and delete the original data
  501. /// - Parameters:
  502. /// - vm: VM to move
  503. /// - url: Location to move to (must be writable)
  504. func move(vm: VMData, to url: URL) async throws {
  505. try await export(vm: vm, to: url)
  506. guard let newVM = try? VMData(url: url) else {
  507. throw UTMDataError.shortcutCreationFailed
  508. }
  509. try await newVM.wrapped!.updateRegistryFromConfig()
  510. let oldSelected = selectedVM
  511. let index = try await delete(vm: vm, alsoRegistry: false)
  512. listAdd(vm: newVM, at: index)
  513. if oldSelected == vm {
  514. listSelect(vm: newVM)
  515. }
  516. }
  517. /// Open settings modal
  518. /// - Parameter vm: VM to edit settings
  519. func edit(vm: VMData) {
  520. listSelect(vm: vm)
  521. showNewVMSheet = false
  522. showSettingsForCurrentVM()
  523. }
  524. /// Copy configuration but not data from existing VM to a new VM
  525. /// - Parameter vm: Existing VM to copy configuration from
  526. func template(vm: VMData) async throws {
  527. let copy = try UTMQemuConfiguration.load(from: vm.pathUrl)
  528. if let copy = copy as? UTMQemuConfiguration {
  529. copy.information.name = self.newDefaultVMName(base: copy.information.name)
  530. copy.information.uuid = UUID()
  531. copy.drives = []
  532. _ = try await create(config: copy)
  533. }
  534. #if os(macOS)
  535. if let copy = copy as? UTMAppleConfiguration {
  536. copy.information.name = self.newDefaultVMName(base: copy.information.name)
  537. copy.information.uuid = UUID()
  538. copy.drives = []
  539. _ = try await create(config: copy)
  540. }
  541. #endif
  542. showSettingsForCurrentVM()
  543. }
  544. // MARK: - File I/O related
  545. /// Calculate total size of VM and data
  546. /// - Parameter vm: VM to calculate size
  547. /// - Returns: Size in bytes
  548. func computeSize(for vm: VMData) async -> Int64 {
  549. return computeSize(recursiveFor: vm.pathUrl)
  550. }
  551. private func computeSize(recursiveFor url: URL) -> Int64 {
  552. guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]) else {
  553. logger.error("failed to create enumerator for \(url)")
  554. return 0
  555. }
  556. var total: Int64 = 0
  557. for case let fileURL as URL in enumerator {
  558. guard let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), let size = resourceValues.totalFileAllocatedSize else {
  559. continue
  560. }
  561. total += Int64(size)
  562. }
  563. return total
  564. }
  565. /// Calculate size of a single file URL
  566. /// - Parameter url: File URL
  567. /// - Returns: Size in bytes
  568. func computeSize(for url: URL) -> Int64 {
  569. if let resourceValues = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), let size = resourceValues.totalFileAllocatedSize {
  570. return Int64(size)
  571. } else {
  572. return 0
  573. }
  574. }
  575. /// Handles UTM file URLs
  576. ///
  577. /// If .utm is already in the list, select it
  578. /// If .utm is in the Inbox directory, move it to the default storage
  579. /// Otherwise we create a shortcut (default for macOS) or a copy (default for iOS)
  580. /// - Parameter url: File URL to read from
  581. /// - Parameter asShortcut: Create a shortcut rather than a copy
  582. func importUTM(from url: URL, asShortcut: Bool = true) async throws {
  583. guard url.isFileURL else { return }
  584. let isScopedAccess = url.startAccessingSecurityScopedResource()
  585. defer {
  586. if isScopedAccess {
  587. url.stopAccessingSecurityScopedResource()
  588. }
  589. }
  590. logger.info("importing: \(url)")
  591. // attempt to turn temp URL to presistent bookmark early otherwise,
  592. // when stopAccessingSecurityScopedResource() is called, we lose access
  593. let bookmark = try url.persistentBookmarkData()
  594. let url = try URL(resolvingPersistentBookmarkData: bookmark)
  595. let fileBasePath = url.deletingLastPathComponent()
  596. let fileName = url.lastPathComponent
  597. let dest = documentsURL.appendingPathComponent(fileName, isDirectory: true)
  598. if let vm = virtualMachines.first(where: { vm -> Bool in
  599. return vm.pathUrl.standardizedFileURL == url.standardizedFileURL
  600. }) {
  601. logger.info("found existing vm!")
  602. if !vm.isLoaded {
  603. logger.info("existing vm is wrapped")
  604. try vm.load()
  605. } else {
  606. logger.info("existing vm is not wrapped")
  607. listSelect(vm: vm)
  608. }
  609. return
  610. }
  611. // check if VM is valid
  612. guard let _ = try? VMData(url: url) else {
  613. throw UTMDataError.importFailed
  614. }
  615. let vm: VMData?
  616. if (fileBasePath.resolvingSymlinksInPath().path == documentsURL.appendingPathComponent("Inbox", isDirectory: true).path) {
  617. logger.info("moving from Inbox")
  618. try fileManager.moveItem(at: url, to: dest)
  619. vm = try VMData(url: dest)
  620. } else if asShortcut {
  621. logger.info("loading as a shortcut")
  622. vm = try VMData(url: url)
  623. } else {
  624. logger.info("copying to Documents")
  625. try fileManager.copyItem(at: url, to: dest)
  626. vm = try VMData(url: dest)
  627. }
  628. guard let vm = vm else {
  629. throw UTMDataError.importParseFailed
  630. }
  631. listAdd(vm: vm)
  632. listSelect(vm: vm)
  633. // warn user if imported .utm has custom arguments
  634. if let qemuConfig = vm.wrapped?.config as? UTMQemuConfiguration, !qemuConfig.qemu.additionalArguments.isEmpty {
  635. showLocalizedErrorAlert("This virtual machine uses custom QEMU arguments which is potentially dangerous and can cause damage to your machine. You should only run this virtual machine if you trust it.")
  636. }
  637. }
  638. /// Handles UTM file URLs similar to importUTM, with few differences
  639. ///
  640. /// Always creates new VM (no shortcuts)
  641. /// Copies VM file with a unique name to default storage (to avoid duplicates)
  642. /// Returns VM data Object (to access UUID)
  643. /// - Parameter url: File URL to read from
  644. func importNewUTM(from url: URL) async throws -> VMData {
  645. guard url.isFileURL else {
  646. throw UTMDataError.importFailed
  647. }
  648. let isScopedAccess = url.startAccessingSecurityScopedResource()
  649. defer {
  650. if isScopedAccess {
  651. url.stopAccessingSecurityScopedResource()
  652. }
  653. }
  654. logger.info("importing: \(url)")
  655. // attempt to turn temp URL to presistent bookmark early otherwise,
  656. // when stopAccessingSecurityScopedResource() is called, we lose access
  657. let bookmark = try url.persistentBookmarkData()
  658. let url = try URL(resolvingPersistentBookmarkData: bookmark)
  659. // get unique filename, for every import we create a new VM
  660. let newUrl = UTMData.newImage(from: url, to: documentsURL)
  661. let fileName = newUrl.lastPathComponent
  662. // create destination name (default storage + file name)
  663. let dest = documentsURL.appendingPathComponent(fileName, isDirectory: true)
  664. // check if VM is valid
  665. guard let _ = try? VMData(url: url) else {
  666. throw UTMDataError.importFailed
  667. }
  668. // Copy file to documents
  669. let vm: VMData?
  670. logger.info("copying to Documents")
  671. try fileManager.copyItem(at: url, to: dest)
  672. vm = try VMData(url: dest)
  673. guard let vm = vm else {
  674. throw UTMDataError.importParseFailed
  675. }
  676. // Add vm to the list
  677. listAdd(vm: vm)
  678. listSelect(vm: vm)
  679. return vm
  680. }
  681. private func copyItemWithCopyfile(at srcURL: URL, to dstURL: URL) async throws {
  682. let totalSize = computeSize(recursiveFor: srcURL)
  683. var lastUpdate = Date()
  684. var lastProgress: CopyManager.Progress?
  685. var copiedSize: Int64 = 0
  686. defer {
  687. busyProgress = nil
  688. }
  689. for try await progress in CopyManager.default.copyItemProgress(at: srcURL, to: dstURL, flags: [.all, .recursive, .clone, .dataSparse]) {
  690. if let _lastProgress = lastProgress, _lastProgress.srcPath != _lastProgress.srcPath {
  691. copiedSize += _lastProgress.bytesCopied
  692. lastProgress = progress
  693. } else {
  694. lastProgress = progress
  695. }
  696. if totalSize > 0 && lastUpdate.timeIntervalSinceNow < -1 {
  697. lastUpdate = Date()
  698. let completed = Float(copiedSize + progress.bytesCopied) / Float(totalSize)
  699. busyProgress = completed > 1.0 ? 1.0 : completed
  700. }
  701. }
  702. }
  703. private func isDirectoryEmpty(_ pathURL: URL) -> Bool {
  704. guard let enumerator = fileManager.enumerator(at: pathURL, includingPropertiesForKeys: [.isDirectoryKey]) else {
  705. return false
  706. }
  707. for case let itemURL as URL in enumerator {
  708. let isDirectory = (try? itemURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
  709. if !isDirectory {
  710. return false
  711. }
  712. }
  713. // if we get here, we only found empty directories
  714. return true
  715. }
  716. // MARK: - Downloading VMs
  717. #if os(macOS) && arch(arm64)
  718. /// Create a new VM using configuration and downloaded IPSW
  719. /// - Parameter config: Apple VM configuration
  720. @available(macOS 12, *)
  721. func downloadIPSW(using config: UTMAppleConfiguration) async {
  722. let task = UTMDownloadIPSWTask(for: config)
  723. guard !virtualMachines.contains(where: { !$0.isShortcut && $0.config?.information.name == config.information.name }) else {
  724. showErrorAlert(message: NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData"))
  725. return
  726. }
  727. listAdd(pendingVM: task.pendingVM)
  728. Task {
  729. do {
  730. if let wrapped = try await task.download() {
  731. let vm = VMData(wrapping: wrapped)
  732. try await self.save(vm: vm)
  733. listAdd(vm: vm)
  734. }
  735. } catch {
  736. showErrorAlert(message: error.localizedDescription)
  737. }
  738. listRemove(pendingVM: task.pendingVM)
  739. }
  740. }
  741. #endif
  742. /// Create a new VM by downloading a .zip and extracting it
  743. /// - Parameter components: Download URL components
  744. func downloadUTMZip(from url: URL) {
  745. let task = UTMDownloadVMTask(for: url)
  746. listAdd(pendingVM: task.pendingVM)
  747. Task {
  748. do {
  749. if let wrapped = try await task.download() {
  750. let vm = VMData(wrapping: wrapped)
  751. try await self.save(vm: vm)
  752. listAdd(vm: vm)
  753. }
  754. } catch {
  755. showErrorAlert(message: error.localizedDescription)
  756. }
  757. listRemove(pendingVM: task.pendingVM)
  758. }
  759. }
  760. private func mountWindowsSupportTools(for vm: any UTMSpiceVirtualMachine) async throws {
  761. let task = UTMDownloadSupportToolsTask(for: vm)
  762. if await task.hasExistingSupportTools {
  763. _ = try await task.mountTools()
  764. } else {
  765. listAdd(pendingVM: task.pendingVM)
  766. Task {
  767. do {
  768. _ = try await task.download()
  769. } catch {
  770. showErrorAlert(message: error.localizedDescription)
  771. }
  772. listRemove(pendingVM: task.pendingVM)
  773. }
  774. }
  775. }
  776. #if os(macOS)
  777. @available(macOS 15, *)
  778. private func mountMacSupportTools(for vm: UTMAppleVirtualMachine) async throws {
  779. let task = UTMDownloadMacSupportToolsTask(for: vm)
  780. if await task.hasExistingSupportTools {
  781. _ = try await task.mountTools()
  782. } else {
  783. listAdd(pendingVM: task.pendingVM)
  784. Task {
  785. do {
  786. _ = try await task.download()
  787. } catch {
  788. showErrorAlert(message: error.localizedDescription)
  789. }
  790. listRemove(pendingVM: task.pendingVM)
  791. }
  792. }
  793. }
  794. #endif
  795. func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
  796. if let vm = vm as? any UTMSpiceVirtualMachine {
  797. return try await mountWindowsSupportTools(for: vm)
  798. }
  799. #if os(macOS)
  800. if #available(macOS 15, *), let vm = vm as? UTMAppleVirtualMachine, vm.config.system.boot.operatingSystem == .macOS {
  801. return try await mountMacSupportTools(for: vm)
  802. }
  803. #endif
  804. throw UTMDataError.unsupportedBackend
  805. }
  806. /// Cancel a download and discard any data
  807. /// - Parameter pendingVM: Pending VM to cancel
  808. func cancelDownload(for pendingVM: UTMPendingVirtualMachine) {
  809. pendingVM.cancel()
  810. }
  811. // MARK: - Reclaim space
  812. #if os(macOS)
  813. /// Reclaim empty space in a file by (re)-converting it to QCOW2
  814. ///
  815. /// This will overwrite driveUrl with the converted file on success!
  816. /// - Parameter driveUrl: Original drive to convert
  817. /// - Parameter isCompressed: Compress existing data
  818. func reclaimSpace(for driveUrl: URL, withCompression isCompressed: Bool = false) async throws {
  819. let baseUrl = driveUrl.deletingLastPathComponent()
  820. let dstUrl = Self.newImage(from: driveUrl, to: baseUrl, withExtension: "qcow2")
  821. defer {
  822. busyProgress = nil
  823. }
  824. try await UTMQemuImage.convert(from: driveUrl, toQcow2: dstUrl, withCompression: isCompressed) { progress in
  825. Task { @MainActor in
  826. self.busyProgress = progress / 100
  827. }
  828. }
  829. busyProgress = nil
  830. do {
  831. try fileManager.replaceItem(at: driveUrl, withItemAt: dstUrl, backupItemName: nil, resultingItemURL: nil)
  832. } catch {
  833. // on failure delete the converted file
  834. try? fileManager.removeItem(at: dstUrl)
  835. throw error
  836. }
  837. }
  838. func qcow2DriveSize(for driveUrl: URL) async -> Int64 {
  839. return (try? await UTMQemuImage.size(image: driveUrl)) ?? 0
  840. }
  841. func resizeQcow2Drive(for driveUrl: URL, sizeInMib: Int) async throws {
  842. let bytesinMib = 1048576
  843. try await UTMQemuImage.resize(image: driveUrl, size: UInt64(sizeInMib * bytesinMib))
  844. }
  845. @available(macOS 14, *)
  846. func appleDriveInfo(for driveUrl: URL) -> (format: String?, size: Int64?) {
  847. var format: String? = nil
  848. var size: Int64? = nil
  849. guard let info = try? UTMASIFImage.sharedInstance()?.retrieveInfo(driveUrl) else {
  850. return (format, size)
  851. }
  852. if let _format = info["Image Format"] as? String {
  853. format = _format
  854. }
  855. if let _sizeInfo = info["Size Info"] as? [String: Any] {
  856. if let _totalBytes = _sizeInfo["Total Bytes"] as? Int64 {
  857. size = _totalBytes
  858. }
  859. }
  860. return (format, size)
  861. }
  862. @available(macOS 14, *)
  863. func resizeAppleDrive(for driveUrl: URL, sizeInMib: Int) throws {
  864. let bytesinMib = 1048576
  865. let size = Int(sizeInMib * bytesinMib)
  866. try UTMASIFImage.sharedInstance()!.resize(with: driveUrl, size: size)
  867. }
  868. #endif
  869. // MARK: - UUID migration
  870. private func uuidHasCollision(with vm: VMData) -> Bool {
  871. return uuidHasCollision(with: vm, in: virtualMachines)
  872. }
  873. private func uuidHasCollision(with vm: VMData, in list: [VMData]) -> Bool {
  874. for otherVM in list {
  875. if otherVM == vm {
  876. return false
  877. } else if let lhs = otherVM.registryEntry?.uuid, let rhs = vm.registryEntry?.uuid, lhs == rhs {
  878. return true
  879. }
  880. }
  881. return false
  882. }
  883. private func uuidRegenerate(for vm: VMData) {
  884. guard let vm = vm.wrapped else {
  885. return
  886. }
  887. vm.changeUuid(to: UUID(), name: nil, copyingEntry: vm.registryEntry)
  888. }
  889. // MARK: - Change listener
  890. private func beginObservingChanges() {
  891. #if WITH_SERVER
  892. listChangedListener = $virtualMachines.sink { vms in
  893. Task {
  894. await self.remoteServer.broadcast { remote in
  895. try await remote.listHasChanged(ids: vms.map({ $0.id }))
  896. }
  897. }
  898. }
  899. #endif
  900. }
  901. private func beginObservingChanges(for vm: VMData) {
  902. #if WITH_SERVER
  903. var observers = Set<AnyCancellable>()
  904. let registryEntry = vm.registryEntry
  905. observers.insert(vm.objectWillChange.sink { [self] _ in
  906. // reset observers when registry changes
  907. if vm.registryEntry != registryEntry {
  908. endObservingChanges(for: vm)
  909. beginObservingChanges(for: vm)
  910. }
  911. })
  912. observers.insert(vm.$state.sink { state in
  913. Task {
  914. let isTakeoverAllowed = self.vmWindows[vm] is VMRemoteSessionState && (state == .started || state == .paused)
  915. await self.remoteServer.broadcast { remote in
  916. try await remote.virtualMachine(id: vm.id, didTransitionToState: state, isTakeoverAllowed: isTakeoverAllowed)
  917. }
  918. }
  919. })
  920. if let registryEntry = registryEntry {
  921. observers.insert(registryEntry.externalDrivePublisher.sink { drives in
  922. let mountedDrives = drives.mapValues({ $0.path })
  923. Task {
  924. await self.remoteServer.broadcast { remote in
  925. try await remote.mountedDrivesHasChanged(id: vm.id, mountedDrives: mountedDrives)
  926. }
  927. }
  928. })
  929. }
  930. remoteChangeListeners[vm] = observers
  931. #endif
  932. }
  933. private func endObservingChanges(for vm: VMData) {
  934. #if WITH_SERVER
  935. remoteChangeListeners.removeValue(forKey: vm)
  936. #endif
  937. }
  938. // MARK: - Other utility functions
  939. /// In some regions, iOS will prompt the user for network access
  940. func triggeriOSNetworkAccessPrompt() {
  941. let task = URLSession.shared.dataTask(with: URL(string: "http://captive.apple.com")!)
  942. task.resume()
  943. }
  944. /// Execute a task with spinning progress indicator
  945. /// - Parameter work: Function to execute
  946. func busyWork(_ work: @escaping () throws -> Void) {
  947. busyQueue.async {
  948. DispatchQueue.main.async {
  949. self.busy = true
  950. }
  951. defer {
  952. DispatchQueue.main.async {
  953. self.busy = false
  954. }
  955. }
  956. do {
  957. try work()
  958. } catch {
  959. logger.error("\(error)")
  960. DispatchQueue.main.async {
  961. self.alertItem = .message(error.localizedDescription)
  962. }
  963. }
  964. }
  965. }
  966. /// Execute a task with spinning progress indicator (Swift concurrency version)
  967. /// - Parameter work: Function to execute
  968. @discardableResult
  969. func busyWorkAsync<T>(_ work: @escaping @Sendable () async throws -> T) -> Task<T, any Error> {
  970. Task.detached(priority: .userInitiated) {
  971. await self.setBusyIndicator(true)
  972. do {
  973. let result = try await work()
  974. await self.setBusyIndicator(false)
  975. return result
  976. } catch {
  977. logger.error("\(error)")
  978. await self.showErrorAlert(message: error.localizedDescription)
  979. await self.setBusyIndicator(false)
  980. throw error
  981. }
  982. }
  983. }
  984. // MARK: - AltKit
  985. #if canImport(AltKit) && WITH_JIT
  986. /// Detect if we are installed from AltStore and can use AltJIT
  987. var isAltServerCompatible: Bool {
  988. guard let _ = Bundle.main.infoDictionary?["ALTServerID"] else {
  989. return false
  990. }
  991. guard let _ = Bundle.main.infoDictionary?["ALTDeviceID"] else {
  992. return false
  993. }
  994. return true
  995. }
  996. /// Find and run AltJIT to enable JIT
  997. func startAltJIT() throws {
  998. let event = DispatchSemaphore(value: 0)
  999. var connectError: Error?
  1000. DispatchQueue.main.async {
  1001. ServerManager.shared.autoconnect { result in
  1002. switch result
  1003. {
  1004. case .failure(let error):
  1005. logger.error("Could not auto-connect to server. \(error.localizedDescription)")
  1006. connectError = error
  1007. event.signal()
  1008. case .success(let connection):
  1009. connection.enableUnsignedCodeExecution { result in
  1010. switch result
  1011. {
  1012. case .failure(let error):
  1013. logger.error("Could not enable JIT compilation. \(error.localizedDescription)")
  1014. connectError = error
  1015. case .success:
  1016. logger.debug("Successfully enabled JIT compilation!")
  1017. Main.jitAvailable = true
  1018. }
  1019. connection.disconnect()
  1020. event.signal()
  1021. }
  1022. }
  1023. }
  1024. ServerManager.shared.startDiscovering()
  1025. }
  1026. defer {
  1027. ServerManager.shared.stopDiscovering()
  1028. }
  1029. if event.wait(timeout: .now() + 10) == .timedOut {
  1030. throw UTMDataError.altServerNotFound
  1031. } else if let error = connectError {
  1032. throw UTMDataError.altJitError(error.localizedDescription)
  1033. }
  1034. }
  1035. #endif
  1036. // MARK - JitStreamer
  1037. #if os(iOS) || os(visionOS)
  1038. @available(iOS 15, *)
  1039. func jitStreamerAttach() async throws {
  1040. let urlString = String(
  1041. format: "http://%@/attach/%ld/",
  1042. UserDefaults.standard.string(forKey: "JitStreamerAddress") ?? "",
  1043. getpid()
  1044. )
  1045. if let url = URL(string: urlString) {
  1046. var request = URLRequest(url: url)
  1047. request.httpMethod = "POST"
  1048. request.httpBody = "".data(using: .utf8)
  1049. var attachError: Error?
  1050. do {
  1051. let (data, _) = try await URLSession.shared.data(for: request)
  1052. let attachResponse = try JSONDecoder().decode(AttachResponse.self, from: data)
  1053. if !attachResponse.success {
  1054. attachError = String.localizedStringWithFormat(NSLocalizedString("Failed to attach to JitStreamer:\n%@", comment: "ContentView"), attachResponse.message)
  1055. } else {
  1056. Main.jitAvailable = true
  1057. }
  1058. } catch is DecodingError {
  1059. throw UTMDataError.jitStreamerDecodeFailed
  1060. } catch {
  1061. throw UTMDataError.jitStreamerAttachFailed
  1062. }
  1063. if let attachError = attachError {
  1064. throw attachError
  1065. }
  1066. } else {
  1067. throw UTMDataError.jitStreamerUrlInvalid(urlString)
  1068. }
  1069. }
  1070. private struct AttachResponse: Decodable {
  1071. var message: String
  1072. var success: Bool
  1073. }
  1074. #endif
  1075. }
  1076. // MARK: - Errors
  1077. enum UTMDataError: Error {
  1078. case virtualMachineAlreadyExists
  1079. case virtualMachineUnavailable
  1080. case unsupportedBackend
  1081. case cloneFailed
  1082. case shortcutCreationFailed
  1083. case importFailed
  1084. case importParseFailed
  1085. case altServerNotFound
  1086. case altJitError(String)
  1087. case jitStreamerDecodeFailed
  1088. case jitStreamerAttachFailed
  1089. case jitStreamerUrlInvalid(String)
  1090. case notImplemented
  1091. case reconnectFailed
  1092. }
  1093. extension UTMDataError: LocalizedError {
  1094. var errorDescription: String? {
  1095. switch self {
  1096. case .virtualMachineAlreadyExists:
  1097. return NSLocalizedString("An existing virtual machine already exists with this name.", comment: "UTMData")
  1098. case .virtualMachineUnavailable:
  1099. return NSLocalizedString("This virtual machine is currently unavailable, make sure it is not open in another session.", comment: "UTMData")
  1100. case .unsupportedBackend:
  1101. return NSLocalizedString("Operation not supported by the backend.", comment: "UTMData")
  1102. case .cloneFailed:
  1103. return NSLocalizedString("Failed to clone VM.", comment: "UTMData")
  1104. case .shortcutCreationFailed:
  1105. return NSLocalizedString("Unable to add a shortcut to the new location.", comment: "UTMData")
  1106. case .importFailed:
  1107. 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")
  1108. case .importParseFailed:
  1109. return NSLocalizedString("Failed to parse imported VM.", comment: "UTMData")
  1110. case .altServerNotFound:
  1111. return NSLocalizedString("Cannot find AltServer for JIT enable. You cannot run VMs until JIT is enabled.", comment: "UTMData")
  1112. case .altJitError(let message):
  1113. return String.localizedStringWithFormat(NSLocalizedString("AltJIT error: %@", comment: "UTMData"), message)
  1114. case .jitStreamerDecodeFailed:
  1115. return NSLocalizedString("Failed to decode JitStreamer response.", comment: "UTMData")
  1116. case .jitStreamerAttachFailed:
  1117. return NSLocalizedString("Failed to attach to JitStreamer.", comment: "UTMData")
  1118. case .jitStreamerUrlInvalid(let urlString):
  1119. return String.localizedStringWithFormat(NSLocalizedString("Invalid JitStreamer attach URL:\n%@", comment: "UTMData"), urlString)
  1120. case .notImplemented:
  1121. return NSLocalizedString("This functionality is not yet implemented.", comment: "UTMData")
  1122. case .reconnectFailed:
  1123. return NSLocalizedString("Failed to reconnect to the server.", comment: "UTMData")
  1124. }
  1125. }
  1126. }
  1127. // MARK: - Remote Client
  1128. /// Declare host capabilities to any remote client
  1129. struct UTMCapabilities: OptionSet, Codable {
  1130. let rawValue: UInt
  1131. /// If set, no trick is needed to get JIT working as the process is entitled.
  1132. static let hasJitEntitlements = Self(rawValue: 1 << 0)
  1133. /// If set, virtualization is supported by this host.
  1134. static let hasHypervisorSupport = Self(rawValue: 1 << 1)
  1135. /// If set, host is aarch64
  1136. static let isAarch64 = Self(rawValue: 1 << 2)
  1137. /// If set, host is x86_64
  1138. static let isX86_64 = Self(rawValue: 1 << 3)
  1139. static fileprivate(set) var current: Self = {
  1140. var current = Self()
  1141. #if WITH_JIT
  1142. if jb_has_jit_entitlement() {
  1143. current.insert(.hasJitEntitlements)
  1144. }
  1145. if jb_has_hypervisor() {
  1146. current.insert(.hasHypervisorSupport)
  1147. }
  1148. #endif
  1149. #if arch(arm64)
  1150. current.insert(.isAarch64)
  1151. #endif
  1152. #if arch(x86_64)
  1153. current.insert(.isX86_64)
  1154. #endif
  1155. return current
  1156. }()
  1157. }
  1158. #if WITH_REMOTE
  1159. private let kReconnectTimeoutSeconds: UInt64 = 5
  1160. @MainActor
  1161. class UTMRemoteData: UTMData {
  1162. /// Remote access client
  1163. private(set) var remoteClient: UTMRemoteClient!
  1164. override init() {
  1165. super.init()
  1166. self.remoteClient = UTMRemoteClient(data: self)
  1167. }
  1168. override func listLoadFromDefaults() {
  1169. // do nothing since we do not load from VMList
  1170. }
  1171. override func listRefresh() async {
  1172. busyWorkAsync {
  1173. try await self.listRefreshFromRemote()
  1174. }
  1175. }
  1176. func reconnect(to server: UTMRemoteClient.State.SavedServer) async throws {
  1177. var reconnectTask: Task<UTMRemoteClient.Remote, any Error>?
  1178. let timeoutTask = Task {
  1179. try await Task.sleep(nanoseconds: kReconnectTimeoutSeconds * NSEC_PER_SEC)
  1180. reconnectTask?.cancel()
  1181. }
  1182. reconnectTask = busyWorkAsync { [self] in
  1183. do {
  1184. try await remoteClient.connect(server)
  1185. } catch is CancellationError {
  1186. throw UTMDataError.reconnectFailed
  1187. }
  1188. timeoutTask.cancel()
  1189. try await listRefreshFromRemote()
  1190. return await remoteClient.server
  1191. }
  1192. // make all active sessions wait on the reconnect
  1193. for session in VMSessionState.allActiveSessions.values {
  1194. let vm = session.vm as! UTMRemoteSpiceVirtualMachine
  1195. Task {
  1196. do {
  1197. try await vm.reconnectServer {
  1198. try await reconnectTask!.value
  1199. }
  1200. } catch {
  1201. session.stop()
  1202. }
  1203. }
  1204. }
  1205. _ = try await reconnectTask!.value
  1206. }
  1207. private func listRefreshFromRemote() async throws {
  1208. if let capabilities = await self.remoteClient.server.capabilities {
  1209. UTMCapabilities.current = capabilities
  1210. }
  1211. let ids = try await remoteClient.server.listVirtualMachines()
  1212. let items = try await remoteClient.server.getVirtualMachineInformation(for: ids)
  1213. let openSessionVms = VMSessionState.allActiveSessions.values.map({ $0.vm })
  1214. let vms = items.map { item in
  1215. let wrapped = openSessionVms.first(where: { $0.id == item.id }) as? UTMRemoteSpiceVirtualMachine
  1216. return VMRemoteData(fromRemoteItem: item, existingWrapped: wrapped)
  1217. }
  1218. await loadVirtualMachines(vms)
  1219. }
  1220. private func loadVirtualMachines(_ vms: [VMData]) async {
  1221. listReplace(with: vms)
  1222. for vm in vms {
  1223. let remoteVM = vm as! VMRemoteData
  1224. if remoteVM.isLoaded {
  1225. continue
  1226. }
  1227. do {
  1228. try await remoteVM.load(withRemoteServer: remoteClient.server)
  1229. } catch {
  1230. remoteVM.unavailableReason = error.localizedDescription
  1231. }
  1232. await Task.yield()
  1233. }
  1234. }
  1235. func remoteListHasChanged(ids: [UUID]) async {
  1236. var existing = virtualMachines.reduce(into: [:]) { partialResult, vm in
  1237. partialResult[vm.id] = vm
  1238. }
  1239. let new = ids.compactMap { id in
  1240. if existing[id] == nil {
  1241. return id
  1242. } else {
  1243. return nil
  1244. }
  1245. }
  1246. if !new.isEmpty, let newItems = try? await remoteClient.server.getVirtualMachineInformation(for: new) {
  1247. newItems.map({ VMRemoteData(fromRemoteItem: $0) }).forEach { vm in
  1248. existing[vm.id] = vm
  1249. }
  1250. }
  1251. let vms = ids.compactMap({ existing[$0] })
  1252. await loadVirtualMachines(vms)
  1253. }
  1254. func remoteQemuConfigurationHasChanged(id: UUID, configuration: UTMQemuConfiguration) async {
  1255. guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
  1256. return
  1257. }
  1258. await vm.reloadConfiguration(withRemoteServer: remoteClient.server, config: configuration)
  1259. }
  1260. func remoteMountedDrivesHasChanged(id: UUID, mountedDrives: [String: String]) async {
  1261. guard let vm = virtualMachines.first(where: { $0.id == id }) as? VMRemoteData else {
  1262. return
  1263. }
  1264. vm.updateMountedDrives(mountedDrives)
  1265. }
  1266. func remoteVirtualMachineDidTransition(id: UUID, state: UTMVirtualMachineState, isTakeoverAllowed: Bool) async {
  1267. guard let vm = virtualMachines.first(where: { $0.id == id }) else {
  1268. return
  1269. }
  1270. let remoteVM = vm as! VMRemoteData
  1271. let wrapped = remoteVM.wrapped as! UTMRemoteSpiceVirtualMachine
  1272. remoteVM.isTakeoverAllowed = isTakeoverAllowed
  1273. await wrapped.updateRemoteState(state)
  1274. }
  1275. func remoteVirtualMachineDidError(id: UUID, message: String) async {
  1276. if let session = VMSessionState.allActiveSessions.values.first(where: { $0.vm.id == id }) {
  1277. session.nonfatalError = message
  1278. }
  1279. }
  1280. override func listMove(fromOffsets: IndexSet, toOffset: Int) {
  1281. let ids = fromOffsets.map({ virtualMachines[$0].id })
  1282. Task {
  1283. try await remoteClient.server.reorderVirtualMachines(fromIds: ids, toOffset: toOffset)
  1284. }
  1285. super.listMove(fromOffsets: fromOffsets, toOffset: toOffset)
  1286. }
  1287. override func save(vm: VMData) async throws {
  1288. throw UTMDataError.notImplemented
  1289. }
  1290. override func discardChanges(for vm: VMData) throws {
  1291. throw UTMDataError.notImplemented
  1292. }
  1293. override func create<Config: UTMConfiguration>(config: Config) async throws -> VMData {
  1294. throw UTMDataError.notImplemented
  1295. }
  1296. @discardableResult
  1297. override func delete(vm: VMData, alsoRegistry: Bool) async throws -> Int? {
  1298. throw UTMDataError.notImplemented
  1299. }
  1300. @discardableResult
  1301. override func clone(vm: VMData) async throws -> VMData {
  1302. throw UTMDataError.notImplemented
  1303. }
  1304. override func export(vm: VMData, to url: URL) async throws {
  1305. throw UTMDataError.notImplemented
  1306. }
  1307. override func move(vm: VMData, to url: URL) async throws {
  1308. throw UTMDataError.notImplemented
  1309. }
  1310. override func template(vm: VMData) async throws {
  1311. throw UTMDataError.notImplemented
  1312. }
  1313. override func computeSize(for vm: VMData) async -> Int64 {
  1314. (try? await remoteClient.server.getPackageSize(for: vm.id)) ?? 0
  1315. }
  1316. override func importUTM(from url: URL, asShortcut: Bool) async throws {
  1317. throw UTMDataError.notImplemented
  1318. }
  1319. override func mountSupportTools(for vm: any UTMVirtualMachine) async throws {
  1320. try await remoteClient.server.mountGuestToolsOnVirtualMachine(id: vm.id)
  1321. }
  1322. }
  1323. #endif