VMData.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. //
  2. // Copyright © 2023 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 Combine
  17. import SwiftUI
  18. /// Model wrapping a single UTMVirtualMachine for use in views
  19. @MainActor class VMData: ObservableObject {
  20. /// Underlying virtual machine
  21. fileprivate(set) var wrapped: (any UTMVirtualMachine)? {
  22. willSet {
  23. objectWillChange.send()
  24. }
  25. didSet {
  26. subscribeToChildren()
  27. }
  28. }
  29. /// Virtual machine configuration
  30. var config: (any UTMConfiguration)? {
  31. wrapped?.config
  32. }
  33. /// Current path of the VM
  34. var pathUrl: URL {
  35. if let wrapped = wrapped {
  36. return wrapped.pathUrl
  37. } else if let registryEntry = registryEntry {
  38. return registryEntry.package.url
  39. } else {
  40. fatalError()
  41. }
  42. }
  43. /// Virtual machine state
  44. var registryEntry: UTMRegistryEntry? {
  45. wrapped?.registryEntry ??
  46. registryEntryWrapped
  47. }
  48. /// Registry entry before loading
  49. fileprivate var registryEntryWrapped: UTMRegistryEntry?
  50. /// Set when we use a temporary UUID because we loaded a legacy entry
  51. private var uuidUnknown: Bool = false
  52. /// Display VM as "deleted" for UI elements
  53. ///
  54. /// This is a workaround for SwiftUI bugs not hiding deleted elements.
  55. @Published var isDeleted: Bool = false
  56. /// Copy from wrapped VM
  57. @Published var state: UTMVirtualMachineState = .stopped
  58. /// Copy from wrapped VM
  59. @Published var screenshot: UTMVirtualMachineScreenshot?
  60. /// If true, it is possible to hijack the session.
  61. @Published var isTakeoverAllowed: Bool = false
  62. /// Allows changes in the config, registry, and VM to be reflected
  63. private var observers: [AnyCancellable] = []
  64. /// True if the .utm is loaded outside of the default storage
  65. var isShortcut: Bool {
  66. isShortcut(pathUrl)
  67. }
  68. /// No default init
  69. fileprivate init() {
  70. }
  71. /// Create a VM from an existing object
  72. /// - Parameter vm: VM to wrap
  73. convenience init(wrapping vm: any UTMVirtualMachine) {
  74. self.init()
  75. self.wrapped = vm
  76. subscribeToChildren()
  77. }
  78. /// Attempt to a new wrapped UTM VM from a file path
  79. /// - Parameter url: File path
  80. convenience init(url: URL) throws {
  81. self.init()
  82. try load(from: url)
  83. }
  84. /// Create a new wrapped UTM VM from a registry entry
  85. /// - Parameter registryEntry: Registry entry
  86. convenience init(from registryEntry: UTMRegistryEntry) {
  87. self.init()
  88. self.registryEntryWrapped = registryEntry
  89. subscribeToChildren()
  90. }
  91. /// Create a new wrapped UTM VM from a dictionary (legacy support)
  92. /// - Parameter info: Dictionary info
  93. convenience init?(from info: [String: Any]) {
  94. guard let bookmark = info["Bookmark"] as? Data,
  95. let name = info["Name"] as? String,
  96. let pathString = info["Path"] as? String else {
  97. return nil
  98. }
  99. let legacyEntry = UTMRegistry.shared.entry(uuid: UUID(), name: name, path: pathString, bookmark: bookmark)
  100. self.init(from: legacyEntry)
  101. uuidUnknown = true
  102. }
  103. /// Create a new wrapped UTM VM from only the bookmark data (legacy support)
  104. /// - Parameter bookmark: Bookmark data
  105. convenience init(bookmark: Data) {
  106. self.init()
  107. let uuid = UUID()
  108. let name = NSLocalizedString("(Unavailable)", comment: "VMData")
  109. let pathString = "/\(UUID().uuidString)"
  110. let legacyEntry = UTMRegistry.shared.entry(uuid: uuid, name: name, path: pathString, bookmark: bookmark)
  111. self.init(from: legacyEntry)
  112. uuidUnknown = true
  113. }
  114. /// Create a new VM from a configuration
  115. /// - Parameter config: Configuration to create new VM
  116. convenience init<Config: UTMConfiguration>(creatingFromConfig config: Config, destinationUrl: URL) throws {
  117. self.init()
  118. #if !WITH_REMOTE
  119. if let qemuConfig = config as? UTMQemuConfiguration {
  120. wrapped = try UTMQemuVirtualMachine(newForConfiguration: qemuConfig, destinationUrl: destinationUrl)
  121. }
  122. #endif
  123. #if os(macOS)
  124. if let appleConfig = config as? UTMAppleConfiguration {
  125. wrapped = try UTMAppleVirtualMachine(newForConfiguration: appleConfig, destinationUrl: destinationUrl)
  126. }
  127. #endif
  128. subscribeToChildren()
  129. }
  130. /// Loads the VM
  131. ///
  132. /// If the VM is already loaded, it will return true without doing anything.
  133. /// - Parameter url: URL to load from
  134. /// - Returns: If load was successful
  135. func load() throws {
  136. try load(from: pathUrl)
  137. }
  138. /// Loads the VM from a path
  139. ///
  140. /// If the VM is already loaded, it will return true without doing anything.
  141. /// - Parameter url: URL to load from
  142. /// - Returns: If load was successful
  143. private func load(from url: URL) throws {
  144. guard !isLoaded else {
  145. return
  146. }
  147. var loaded: (any UTMVirtualMachine)?
  148. let config = try UTMQemuConfiguration.load(from: url)
  149. #if !WITH_REMOTE
  150. if let qemuConfig = config as? UTMQemuConfiguration {
  151. loaded = try UTMQemuVirtualMachine(packageUrl: url, configuration: qemuConfig, isShortcut: isShortcut(url))
  152. }
  153. #endif
  154. #if os(macOS)
  155. if let appleConfig = config as? UTMAppleConfiguration {
  156. loaded = try UTMAppleVirtualMachine(packageUrl: url, configuration: appleConfig, isShortcut: isShortcut(url))
  157. }
  158. #endif
  159. guard let vm = loaded else {
  160. throw VMDataError.virtualMachineNotLoaded
  161. }
  162. if let oldEntry = registryEntry, oldEntry.uuid != vm.registryEntry.uuid {
  163. if uuidUnknown {
  164. // legacy VMs don't have UUID stored so we made a fake UUID
  165. UTMRegistry.shared.remove(entry: oldEntry)
  166. } else {
  167. // persistent uuid does not match indicating a cloned or legacy VM with a duplicate UUID
  168. vm.changeUuid(to: oldEntry.uuid, name: nil, copyingEntry: oldEntry)
  169. }
  170. }
  171. wrapped = vm
  172. uuidUnknown = false
  173. vm.updateConfigFromRegistry()
  174. subscribeToChildren()
  175. }
  176. /// Saves the VM to file
  177. func save() async throws {
  178. guard let wrapped = wrapped else {
  179. throw VMDataError.virtualMachineNotLoaded
  180. }
  181. try await wrapped.save()
  182. }
  183. /// Listen to changes in the underlying object and propogate upwards
  184. fileprivate func subscribeToChildren() {
  185. var s: [AnyCancellable] = []
  186. if let wrapped = wrapped {
  187. wrapped.onConfigurationChange = { [weak self] in
  188. self?.objectWillChange.send()
  189. Task { @MainActor in
  190. self?.subscribeToChildren()
  191. }
  192. }
  193. wrapped.onStateChange = { [weak self, weak wrapped] in
  194. Task { @MainActor in
  195. if let wrapped = wrapped {
  196. self?.state = wrapped.state
  197. self?.screenshot = wrapped.screenshot
  198. }
  199. }
  200. }
  201. }
  202. if let qemuConfig = wrapped?.config as? UTMQemuConfiguration {
  203. s.append(qemuConfig.objectWillChange.sink { [weak self] _ in
  204. self?.objectWillChange.send()
  205. })
  206. }
  207. #if os(macOS)
  208. if let appleConfig = wrapped?.config as? UTMAppleConfiguration {
  209. s.append(appleConfig.objectWillChange.sink { [weak self] _ in
  210. self?.objectWillChange.send()
  211. })
  212. }
  213. #endif
  214. if let registryEntry = registryEntry {
  215. s.append(registryEntry.objectWillChange.sink { [weak self] in
  216. self?.objectWillChange.send()
  217. Task { @MainActor in
  218. self?.wrapped?.updateConfigFromRegistry()
  219. }
  220. })
  221. }
  222. observers = s
  223. }
  224. }
  225. // MARK: - Errors
  226. enum VMDataError: Error {
  227. case virtualMachineNotLoaded
  228. }
  229. extension VMDataError: LocalizedError {
  230. var errorDescription: String? {
  231. switch self {
  232. case .virtualMachineNotLoaded:
  233. return NSLocalizedString("Virtual machine not loaded.", comment: "VMData")
  234. }
  235. }
  236. }
  237. // MARK: - Identity
  238. extension VMData: Identifiable {
  239. public var id: UUID {
  240. registryEntry?.uuid ??
  241. config?.information.uuid ??
  242. UUID()
  243. }
  244. }
  245. extension VMData: Equatable {
  246. static func == (lhs: VMData, rhs: VMData) -> Bool {
  247. if lhs.isLoaded && rhs.isLoaded {
  248. return lhs.wrapped === rhs.wrapped
  249. }
  250. if let lhsEntry = lhs.registryEntryWrapped, let rhsEntry = rhs.registryEntryWrapped {
  251. return lhsEntry == rhsEntry
  252. }
  253. return false
  254. }
  255. }
  256. extension VMData: Hashable {
  257. func hash(into hasher: inout Hasher) {
  258. hasher.combine(pathUrl)
  259. hasher.combine(registryEntryWrapped)
  260. hasher.combine(isDeleted)
  261. }
  262. }
  263. // MARK: - VM State
  264. extension VMData {
  265. func isShortcut(_ url: URL) -> Bool {
  266. let defaultStorageUrl = UTMData.defaultStorageUrl.standardizedFileURL
  267. let parentUrl = url.deletingLastPathComponent().standardizedFileURL
  268. return parentUrl != defaultStorageUrl
  269. }
  270. /// VM is loaded
  271. var isLoaded: Bool {
  272. wrapped != nil
  273. }
  274. /// VM is stopped
  275. var isStopped: Bool {
  276. state == .stopped || state == .paused
  277. }
  278. /// VM can be modified
  279. var isModifyAllowed: Bool {
  280. state == .stopped
  281. }
  282. /// Display VM as "busy" for UI elements
  283. var isBusy: Bool {
  284. state == .pausing ||
  285. state == .resuming ||
  286. state == .starting ||
  287. state == .stopping ||
  288. state == .saving ||
  289. state == .resuming
  290. }
  291. /// VM has been suspended before
  292. var hasSuspendState: Bool {
  293. registryEntry?.isSuspended ?? false
  294. }
  295. }
  296. // MARK: - Home UI elements
  297. extension VMData {
  298. /// Unavailable string
  299. private var unavailable: String {
  300. NSLocalizedString("Unavailable", comment: "VMData")
  301. }
  302. /// Display title for UI elements
  303. var detailsTitleLabel: String {
  304. config?.information.name ??
  305. registryEntry?.name ??
  306. unavailable
  307. }
  308. /// Display subtitle for UI elements
  309. var detailsSubtitleLabel: String {
  310. detailsSystemTargetLabel
  311. }
  312. /// Display icon path for UI elements
  313. var detailsIconUrl: URL? {
  314. config?.information.iconURL ?? nil
  315. }
  316. /// Display user-specified notes for UI elements
  317. var detailsNotes: String? {
  318. config?.information.notes ?? nil
  319. }
  320. /// Display VM target system for UI elements
  321. var detailsSystemTargetLabel: String {
  322. if let qemuConfig = config as? UTMQemuConfiguration {
  323. return qemuConfig.system.target.prettyValue
  324. }
  325. #if os(macOS)
  326. if let appleConfig = config as? UTMAppleConfiguration {
  327. return appleConfig.system.boot.operatingSystem.rawValue
  328. }
  329. #endif
  330. return unavailable
  331. }
  332. /// Display VM architecture for UI elements
  333. var detailsSystemArchitectureLabel: String {
  334. if let qemuConfig = config as? UTMQemuConfiguration {
  335. return qemuConfig.system.architecture.prettyValue
  336. }
  337. #if os(macOS)
  338. if let appleConfig = config as? UTMAppleConfiguration {
  339. return appleConfig.system.architecture
  340. }
  341. #endif
  342. return unavailable
  343. }
  344. /// Display RAM (formatted) for UI elements
  345. var detailsSystemMemoryLabel: String {
  346. let bytesInMib = Int64(1048576)
  347. if let qemuConfig = config as? UTMQemuConfiguration {
  348. return ByteCountFormatter.string(fromByteCount: Int64(qemuConfig.system.memorySize) * bytesInMib, countStyle: .binary)
  349. }
  350. #if os(macOS)
  351. if let appleConfig = config as? UTMAppleConfiguration {
  352. return ByteCountFormatter.string(fromByteCount: Int64(appleConfig.system.memorySize) * bytesInMib, countStyle: .binary)
  353. }
  354. #endif
  355. return unavailable
  356. }
  357. /// Display current VM state as a string for UI elements
  358. var stateLabel: String {
  359. switch state {
  360. case .stopped:
  361. if registryEntry?.isSuspended == true {
  362. return NSLocalizedString("Suspended", comment: "VMData");
  363. } else {
  364. return NSLocalizedString("Stopped", comment: "VMData");
  365. }
  366. case .starting:
  367. return NSLocalizedString("Starting", comment: "VMData")
  368. case .started:
  369. return NSLocalizedString("Started", comment: "VMData")
  370. case .pausing:
  371. return NSLocalizedString("Pausing", comment: "VMData")
  372. case .paused:
  373. return NSLocalizedString("Paused", comment: "VMData")
  374. case .resuming:
  375. return NSLocalizedString("Resuming", comment: "VMData")
  376. case .stopping:
  377. return NSLocalizedString("Stopping", comment: "VMData")
  378. case .saving:
  379. return NSLocalizedString("Saving", comment: "VMData")
  380. case .restoring:
  381. return NSLocalizedString("Restoring", comment: "VMData")
  382. }
  383. }
  384. /// If non-null, is the most recent screenshot image of the running VM
  385. var screenshotImage: PlatformImage? {
  386. wrapped?.screenshot?.image
  387. }
  388. }
  389. #if WITH_REMOTE
  390. @MainActor
  391. class VMRemoteData: VMData {
  392. private var backend: UTMBackend
  393. private var _isShortcut: Bool
  394. override var isShortcut: Bool {
  395. _isShortcut
  396. }
  397. private var initialState: UTMVirtualMachineState
  398. private var existingWrapped: UTMRemoteSpiceVirtualMachine?
  399. /// Set by caller when VM is unavailable and there is a reason for it.
  400. @Published var unavailableReason: String?
  401. init(fromRemoteItem item: UTMRemoteMessageServer.VirtualMachineInformation, existingWrapped: UTMRemoteSpiceVirtualMachine? = nil) {
  402. self.backend = item.backend
  403. self._isShortcut = item.isShortcut
  404. self.initialState = item.state
  405. self.existingWrapped = existingWrapped
  406. super.init()
  407. self.isTakeoverAllowed = item.isTakeoverAllowed
  408. self.registryEntryWrapped = UTMRegistry.shared.entry(uuid: item.id, name: item.name, path: item.path)
  409. self.registryEntryWrapped!.isSuspended = item.isSuspended
  410. self.registryEntryWrapped!.externalDrives = item.mountedDrives.mapValues({ UTMRegistryEntry.File(dummyFromPath: $0) })
  411. }
  412. override func load() throws {
  413. throw VMRemoteDataError.notImplemented
  414. }
  415. func load(withRemoteServer server: UTMRemoteClient.Remote) async throws {
  416. guard backend == .qemu else {
  417. throw VMRemoteDataError.backendNotSupported
  418. }
  419. let entry = registryEntryWrapped!
  420. let config = try await server.getQEMUConfiguration(for: entry.uuid)
  421. await loadCustomIcon(withRemoteServer: server, id: entry.uuid, config: config)
  422. let vm: UTMRemoteSpiceVirtualMachine
  423. if let existingWrapped = existingWrapped {
  424. vm = existingWrapped
  425. wrapped = vm
  426. self.existingWrapped = nil
  427. await reloadConfiguration(withRemoteServer: server, config: config)
  428. vm.updateRegistry(entry)
  429. } else {
  430. vm = UTMRemoteSpiceVirtualMachine(forRemoteServer: server, remotePath: entry.package.path, entry: entry, config: config)
  431. wrapped = vm
  432. }
  433. vm.updateConfigFromRegistry()
  434. subscribeToChildren()
  435. await vm.updateRemoteState(initialState)
  436. }
  437. func reloadConfiguration(withRemoteServer server: UTMRemoteClient.Remote, config: UTMQemuConfiguration) async {
  438. let spiceVM = wrapped as! UTMRemoteSpiceVirtualMachine
  439. await loadCustomIcon(withRemoteServer: server, id: spiceVM.id, config: config)
  440. spiceVM.reload(usingConfiguration: config)
  441. }
  442. private func loadCustomIcon(withRemoteServer server: UTMRemoteClient.Remote, id: UUID, config: UTMQemuConfiguration) async {
  443. if config.information.isIconCustom, let iconUrl = config.information.iconURL {
  444. if let iconUrl = try? await server.getPackageFile(for: id, relativePathComponents: [UTMQemuConfiguration.dataDirectoryName, iconUrl.lastPathComponent]) {
  445. config.information.iconURL = iconUrl
  446. }
  447. }
  448. }
  449. func updateMountedDrives(_ mountedDrives: [String: String]) {
  450. guard let registryEntry = registryEntry else {
  451. return
  452. }
  453. registryEntry.externalDrives = mountedDrives.mapValues({ UTMRegistryEntry.File(dummyFromPath: $0) })
  454. }
  455. }
  456. enum VMRemoteDataError: Error {
  457. case notImplemented
  458. case backendNotSupported
  459. }
  460. extension VMRemoteDataError: LocalizedError {
  461. var errorDescription: String? {
  462. switch self {
  463. case .notImplemented:
  464. return NSLocalizedString("This function is not implemented.", comment: "VMData")
  465. case .backendNotSupported:
  466. return NSLocalizedString("This VM is not available or is configured for a backend that does not support remote clients.", comment: "VMData")
  467. }
  468. }
  469. }
  470. #endif