UTMRegistryEntry.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. //
  2. // Copyright © 2022 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 Combine
  18. @objc class UTMRegistryEntry: NSObject, Codable, ObservableObject {
  19. /// Empty registry entry used only as a workaround for object initialization
  20. static let empty = UTMRegistryEntry(uuid: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, name: "", path: "")
  21. @Published private var _name: String
  22. @Published private var _package: File
  23. private(set) var uuid: UUID
  24. @Published private var _isSuspended: Bool
  25. @Published private var _externalDrives: [String: File]
  26. @Published private var _sharedDirectories: [File]
  27. @Published private var _windowSettings: [Int: Window]
  28. @Published private var _terminalSettings: [Int: Terminal]
  29. @Published private var _resolutionSettings: [Int: Resolution]
  30. @Published private var _hasMigratedConfig: Bool
  31. @Published private var _macRecoveryIpsw: File?
  32. private enum CodingKeys: String, CodingKey {
  33. case name = "Name"
  34. case package = "Package"
  35. case uuid = "UUID"
  36. case isSuspended = "Suspended"
  37. case externalDrives = "ExternalDrives"
  38. case sharedDirectories = "SharedDirectories"
  39. case windowSettings = "WindowSettings"
  40. case terminalSettings = "TerminalSettings"
  41. case resolutionSettings = "ResolutionSettings"
  42. case hasMigratedConfig = "MigratedConfig"
  43. case macRecoveryIpsw = "MacRecoveryIpsw"
  44. }
  45. init(uuid: UUID, name: String, path: String, bookmark: Data? = nil) {
  46. _name = name
  47. let package: File?
  48. if let bookmark = bookmark {
  49. package = try? File(path: path, bookmark: bookmark)
  50. } else {
  51. package = nil
  52. }
  53. _package = package ?? File(dummyFromPath: path)
  54. self.uuid = uuid
  55. _isSuspended = false
  56. _externalDrives = [:]
  57. _sharedDirectories = []
  58. _windowSettings = [:]
  59. _terminalSettings = [:]
  60. _resolutionSettings = [:]
  61. _hasMigratedConfig = false
  62. }
  63. convenience init(newFrom vm: any UTMVirtualMachine) {
  64. self.init(uuid: vm.id, name: vm.name, path: vm.pathUrl.path)
  65. if let package = try? File(url: vm.pathUrl) {
  66. _package = package
  67. }
  68. }
  69. required init(from decoder: Decoder) throws {
  70. let container = try decoder.container(keyedBy: CodingKeys.self)
  71. _name = try container.decode(String.self, forKey: .name)
  72. _package = try container.decode(File.self, forKey: .package)
  73. uuid = try container.decode(UUID.self, forKey: .uuid)
  74. _isSuspended = try container.decode(Bool.self, forKey: .isSuspended)
  75. _externalDrives = (try container.decode([String: File].self, forKey: .externalDrives)).filter({ $0.value.isValid })
  76. _sharedDirectories = try container.decode([File].self, forKey: .sharedDirectories).filter({ $0.isValid })
  77. _windowSettings = try container.decode([Int: Window].self, forKey: .windowSettings)
  78. _terminalSettings = try container.decodeIfPresent([Int: Terminal].self, forKey: .terminalSettings) ?? [:]
  79. _resolutionSettings = try container.decodeIfPresent([Int: Resolution].self, forKey: .resolutionSettings) ?? [:]
  80. _hasMigratedConfig = try container.decodeIfPresent(Bool.self, forKey: .hasMigratedConfig) ?? false
  81. _macRecoveryIpsw = try container.decodeIfPresent(File.self, forKey: .macRecoveryIpsw)
  82. }
  83. func encode(to encoder: Encoder) throws {
  84. var container = encoder.container(keyedBy: CodingKeys.self)
  85. try container.encode(_name, forKey: .name)
  86. try container.encode(_package, forKey: .package)
  87. try container.encode(uuid, forKey: .uuid)
  88. try container.encode(_isSuspended, forKey: .isSuspended)
  89. try container.encode(_externalDrives, forKey: .externalDrives)
  90. try container.encode(_sharedDirectories, forKey: .sharedDirectories)
  91. try container.encode(_windowSettings, forKey: .windowSettings)
  92. try container.encode(_terminalSettings, forKey: .terminalSettings)
  93. try container.encode(_resolutionSettings, forKey: .resolutionSettings)
  94. if _hasMigratedConfig {
  95. try container.encode(_hasMigratedConfig, forKey: .hasMigratedConfig)
  96. }
  97. try container.encodeIfPresent(_macRecoveryIpsw, forKey: .macRecoveryIpsw)
  98. }
  99. func asDictionary() throws -> [String: Any] {
  100. return try propertyList() as! [String: Any]
  101. }
  102. /// Update the UUID
  103. ///
  104. /// Should only be called from `UTMRegistry`!
  105. /// - Parameter uuid: UUID to change to
  106. func _updateUuid(_ uuid: UUID) {
  107. self.objectWillChange.send()
  108. self.uuid = uuid
  109. }
  110. }
  111. protocol UTMRegistryEntryDecodable: Decodable {}
  112. extension UTMRegistryEntry: UTMRegistryEntryDecodable {}
  113. // MARK: - Accessors
  114. @MainActor extension UTMRegistryEntry {
  115. var name: String {
  116. get {
  117. _name
  118. }
  119. set {
  120. _name = newValue
  121. }
  122. }
  123. var package: File {
  124. get {
  125. _package
  126. }
  127. set {
  128. _package = newValue
  129. }
  130. }
  131. var isSuspended: Bool {
  132. get {
  133. _isSuspended
  134. }
  135. set {
  136. _isSuspended = newValue
  137. }
  138. }
  139. var externalDrives: [String: File] {
  140. get {
  141. _externalDrives
  142. }
  143. set {
  144. _externalDrives = newValue
  145. }
  146. }
  147. var externalDrivePublisher: Published<[String: File]>.Publisher {
  148. $_externalDrives
  149. }
  150. var sharedDirectories: [File] {
  151. get {
  152. _sharedDirectories
  153. }
  154. set {
  155. _sharedDirectories = newValue
  156. }
  157. }
  158. var windowSettings: [Int: Window] {
  159. get {
  160. _windowSettings
  161. }
  162. set {
  163. _windowSettings = newValue
  164. }
  165. }
  166. var terminalSettings: [Int: Terminal] {
  167. get {
  168. _terminalSettings
  169. }
  170. set {
  171. _terminalSettings = newValue
  172. }
  173. }
  174. var resolutionSettings: [Int: Resolution] {
  175. get {
  176. _resolutionSettings
  177. }
  178. set {
  179. _resolutionSettings = newValue
  180. }
  181. }
  182. var hasMigratedConfig: Bool {
  183. get {
  184. _hasMigratedConfig
  185. }
  186. set {
  187. _hasMigratedConfig = newValue
  188. }
  189. }
  190. var macRecoveryIpsw: File? {
  191. get {
  192. _macRecoveryIpsw
  193. }
  194. set {
  195. _macRecoveryIpsw = newValue
  196. }
  197. }
  198. func setExternalDrive(_ file: File, forId id: String) {
  199. externalDrives[id] = file
  200. }
  201. func updateExternalDriveRemoteBookmark(_ bookmark: Data, forId id: String) {
  202. externalDrives[id]?.remoteBookmark = bookmark
  203. }
  204. func removeExternalDrive(forId id: String) {
  205. externalDrives.removeValue(forKey: id)
  206. }
  207. func setSingleSharedDirectory(_ file: File) {
  208. sharedDirectories = [file]
  209. }
  210. func updateSingleSharedDirectoryRemoteBookmark(_ bookmark: Data) {
  211. if !sharedDirectories.isEmpty {
  212. sharedDirectories[0].remoteBookmark = bookmark
  213. }
  214. }
  215. func appendSharedDirectory(_ file: File) {
  216. sharedDirectories.append(file)
  217. }
  218. func removeAllSharedDirectories() {
  219. sharedDirectories = []
  220. }
  221. func update(copying other: UTMRegistryEntry) {
  222. isSuspended = other.isSuspended
  223. externalDrives = other.externalDrives
  224. sharedDirectories = other.sharedDirectories
  225. windowSettings = other.windowSettings
  226. terminalSettings = other.terminalSettings
  227. resolutionSettings = other.resolutionSettings
  228. hasMigratedConfig = other.hasMigratedConfig
  229. }
  230. func setIsSuspended(_ isSuspended: Bool) {
  231. self.isSuspended = isSuspended
  232. }
  233. func setPackageRemoteBookmark(_ remoteBookmark: Data?, path: String? = nil) {
  234. package.remoteBookmark = remoteBookmark
  235. if let path = path {
  236. package.path = path
  237. }
  238. }
  239. }
  240. // MARK: - Migration from UTMViewState
  241. extension UTMRegistryEntry {
  242. /// Migrate from a view state
  243. /// - Parameter viewState: View state to migrate
  244. private func migrate(viewState: UTMLegacyViewState) {
  245. var primaryWindow = Window()
  246. if viewState.displayScale != .zero {
  247. primaryWindow.scale = viewState.displayScale
  248. }
  249. if viewState.displayOriginX != .zero || viewState.displayOriginY != .zero {
  250. primaryWindow.origin = CGPoint(x: viewState.displayOriginX,
  251. y: viewState.displayOriginY)
  252. }
  253. primaryWindow.isKeyboardVisible = viewState.isKeyboardShown
  254. primaryWindow.isToolbarVisible = viewState.isToolbarShown
  255. if primaryWindow != Window() {
  256. _windowSettings[0] = primaryWindow
  257. }
  258. _isSuspended = viewState.hasSaveState
  259. if let sharedDirectoryBookmark = viewState.sharedDirectory, let sharedDirectoryPath = viewState.sharedDirectoryPath {
  260. if let file = try? File(path: sharedDirectoryPath,
  261. bookmark: sharedDirectoryBookmark) {
  262. _sharedDirectories = [file]
  263. } else {
  264. logger.error("Failed to migrate shared directory \(sharedDirectoryPath) because bookmark is invalid.")
  265. }
  266. }
  267. if let shortcutBookmark = viewState.shortcutBookmark {
  268. _package.remoteBookmark = shortcutBookmark
  269. }
  270. for drive in viewState.allDrives() {
  271. if let bookmark = viewState.bookmark(forRemovableDrive: drive), let path = viewState.path(forRemovableDrive: drive) {
  272. let file = File(dummyFromPath: path, remoteBookmark: bookmark)
  273. _externalDrives[drive] = file
  274. }
  275. }
  276. }
  277. /// Try to migrate from a view.plist or does nothing if it does not exist.
  278. /// - Parameter viewStateURL: URL to view.plist
  279. @objc func migrateUnsafe(viewStateURL: URL) {
  280. let fileManager = FileManager.default
  281. guard fileManager.fileExists(atPath: viewStateURL.path) else {
  282. return
  283. }
  284. guard let dict = try? NSDictionary(contentsOf: viewStateURL, error: ()) as? [AnyHashable : Any] else {
  285. logger.error("Failed to parse legacy \(viewStateURL)")
  286. return
  287. }
  288. let viewState = UTMLegacyViewState(dictionary: dict)
  289. migrate(viewState: viewState)
  290. try? fileManager.removeItem(at: viewStateURL) // delete view.plist
  291. }
  292. #if os(macOS)
  293. /// Try to migrate bookmarks from an Apple VM config.
  294. /// - Parameter config: Apple config to migrate
  295. @MainActor func migrate(fromAppleConfig config: UTMAppleConfiguration) {
  296. for sharedDirectory in config.sharedDirectories {
  297. if let url = sharedDirectory.directoryURL,
  298. let file = try? File(url: url, isReadOnly: sharedDirectory.isReadOnly) {
  299. sharedDirectories.append(file)
  300. } else {
  301. logger.error("Failed to migrate a shared directory from config.")
  302. }
  303. }
  304. for drive in config.drives {
  305. if drive.isExternal, let url = drive.imageURL,
  306. let file = try? File(url: url, isReadOnly: drive.isReadOnly) {
  307. externalDrives[drive.id] = file
  308. } else {
  309. logger.error("Failed to migrate drive \(drive.id) from config.")
  310. }
  311. }
  312. }
  313. #endif
  314. }
  315. extension UTMRegistryEntry {
  316. struct File: Codable, Identifiable {
  317. var url: URL
  318. var path: String
  319. var bookmark: Data
  320. var remoteBookmark: Data?
  321. var isReadOnly: Bool
  322. let id: UUID = UUID()
  323. fileprivate var isValid: Bool
  324. private enum CodingKeys: String, CodingKey {
  325. case path = "Path"
  326. case bookmark = "Bookmark"
  327. case remoteBookmark = "BookmarkRemote"
  328. case isReadOnly = "ReadOnly"
  329. }
  330. init(path: String, bookmark: Data, isReadOnly: Bool = false) throws {
  331. self.path = path
  332. self.bookmark = bookmark
  333. self.isReadOnly = isReadOnly
  334. self.url = try URL(resolvingPersistentBookmarkData: bookmark)
  335. self.isValid = true
  336. }
  337. init(url: URL, isReadOnly: Bool = false) throws {
  338. self.path = url.path
  339. self.bookmark = try url.persistentBookmarkData(isReadyOnly: isReadOnly)
  340. self.isReadOnly = isReadOnly
  341. self.url = url
  342. self.isValid = true
  343. }
  344. init(dummyFromPath path: String, remoteBookmark: Data = Data()) {
  345. self.path = path
  346. self.bookmark = Data()
  347. self.isReadOnly = false
  348. self.url = URL(fileURLWithPath: path)
  349. self.remoteBookmark = remoteBookmark
  350. self.isValid = true
  351. }
  352. init(from decoder: Decoder) throws {
  353. let container = try decoder.container(keyedBy: CodingKeys.self)
  354. path = try container.decode(String.self, forKey: .path)
  355. bookmark = try container.decode(Data.self, forKey: .bookmark)
  356. isReadOnly = try container.decode(Bool.self, forKey: .isReadOnly)
  357. remoteBookmark = try container.decodeIfPresent(Data.self, forKey: .remoteBookmark)
  358. url = URL(fileURLWithPath: path)
  359. if bookmark.isEmpty {
  360. isValid = true
  361. } else {
  362. // we cannot throw because that stops the decode process so we record the error and continue
  363. do {
  364. url = try URL(resolvingPersistentBookmarkData: bookmark)
  365. isValid = true
  366. } catch {
  367. isValid = false
  368. }
  369. }
  370. }
  371. func encode(to encoder: Encoder) throws {
  372. var container = encoder.container(keyedBy: CodingKeys.self)
  373. try container.encode(path, forKey: .path)
  374. try container.encode(bookmark, forKey: .bookmark)
  375. try container.encode(isReadOnly, forKey: .isReadOnly)
  376. try container.encodeIfPresent(remoteBookmark, forKey: .remoteBookmark)
  377. }
  378. }
  379. struct Window: Codable, Equatable {
  380. var scale: CGFloat = 1.0
  381. var origin: CGPoint = .zero
  382. var isToolbarVisible: Bool = true
  383. var isKeyboardVisible: Bool = false
  384. var isDisplayZoomLocked: Bool = true
  385. private enum CodingKeys: String, CodingKey {
  386. case scale = "Scale"
  387. case origin = "Origin"
  388. case isToolbarVisible = "ToolbarVisible"
  389. case isKeyboardVisible = "KeyboardVisible"
  390. case isDisplayZoomLocked = "DisplayZoomLocked"
  391. }
  392. init() {
  393. }
  394. init(from decoder: Decoder) throws {
  395. let container = try decoder.container(keyedBy: CodingKeys.self)
  396. scale = try container.decode(CGFloat.self, forKey: .scale)
  397. origin = try container.decode(CGPoint.self, forKey: .origin)
  398. isToolbarVisible = try container.decode(Bool.self, forKey: .isToolbarVisible)
  399. isKeyboardVisible = try container.decode(Bool.self, forKey: .isKeyboardVisible)
  400. isDisplayZoomLocked = try container.decode(Bool.self, forKey: .isDisplayZoomLocked)
  401. }
  402. func encode(to encoder: Encoder) throws {
  403. var container = encoder.container(keyedBy: CodingKeys.self)
  404. try container.encode(scale, forKey: .scale)
  405. try container.encode(origin, forKey: .origin)
  406. try container.encode(isToolbarVisible, forKey: .isToolbarVisible)
  407. try container.encode(isKeyboardVisible, forKey: .isKeyboardVisible)
  408. try container.encode(isDisplayZoomLocked, forKey: .isDisplayZoomLocked)
  409. }
  410. }
  411. struct Terminal: Codable, Equatable {
  412. var columns: Int
  413. var rows: Int
  414. private enum CodingKeys: String, CodingKey {
  415. case columns = "Columns"
  416. case rows = "Rows"
  417. }
  418. init(columns: Int = 80, rows: Int = 24) {
  419. self.columns = columns
  420. self.rows = rows
  421. }
  422. init(from decoder: Decoder) throws {
  423. let container = try decoder.container(keyedBy: CodingKeys.self)
  424. columns = try container.decode(Int.self, forKey: .columns)
  425. rows = try container.decode(Int.self, forKey: .rows)
  426. }
  427. func encode(to encoder: Encoder) throws {
  428. var container = encoder.container(keyedBy: CodingKeys.self)
  429. try container.encode(columns, forKey: .columns)
  430. try container.encode(rows, forKey: .rows)
  431. }
  432. }
  433. struct Resolution: Codable, Equatable {
  434. var size: CGSize = .zero
  435. var isFullscreen: Bool = false
  436. private enum CodingKeys: String, CodingKey {
  437. case size = "Size"
  438. case isFullscreen = "Fullscreen"
  439. }
  440. init() {}
  441. init(from decoder: Decoder) throws {
  442. let container = try decoder.container(keyedBy: CodingKeys.self)
  443. size = try container.decode(CGSize.self, forKey: .size)
  444. isFullscreen = try container.decode(Bool.self, forKey: .isFullscreen)
  445. }
  446. func encode(to encoder: Encoder) throws {
  447. var container = encoder.container(keyedBy: CodingKeys.self)
  448. try container.encode(size, forKey: .size)
  449. try container.encode(isFullscreen, forKey: .isFullscreen)
  450. }
  451. }
  452. }