UTMQemuVirtualMachine.swift 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011
  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 QEMUKit
  18. #if os(macOS)
  19. import SwiftPortmap
  20. #endif
  21. private var SpiceIoServiceGuestAgentContext = 0
  22. private let kSuspendSnapshotName = "suspend"
  23. private let kProbeSuspendDelay = 1*NSEC_PER_SEC
  24. /// QEMU backend virtual machine
  25. final class UTMQemuVirtualMachine: UTMSpiceVirtualMachine {
  26. struct Capabilities: UTMVirtualMachineCapabilities {
  27. var supportsProcessKill: Bool {
  28. true
  29. }
  30. var supportsSnapshots: Bool {
  31. true
  32. }
  33. var supportsScreenshots: Bool {
  34. true
  35. }
  36. var supportsDisposibleMode: Bool {
  37. true
  38. }
  39. var supportsRecoveryMode: Bool {
  40. false
  41. }
  42. var supportsRemoteSession: Bool {
  43. true
  44. }
  45. }
  46. static let capabilities = Capabilities()
  47. private(set) var pathUrl: URL {
  48. didSet {
  49. if isScopedAccess {
  50. oldValue.stopAccessingSecurityScopedResource()
  51. }
  52. isScopedAccess = pathUrl.startAccessingSecurityScopedResource()
  53. }
  54. }
  55. private(set) var isShortcut: Bool = false
  56. private(set) var isRunningAsDisposible: Bool = false
  57. weak var delegate: (any UTMVirtualMachineDelegate)?
  58. var onConfigurationChange: (() -> Void)?
  59. var onStateChange: (() -> Void)?
  60. private(set) var config: UTMQemuConfiguration {
  61. willSet {
  62. onConfigurationChange?()
  63. }
  64. }
  65. private(set) var registryEntry: UTMRegistryEntry {
  66. willSet {
  67. onConfigurationChange?()
  68. }
  69. }
  70. private(set) var state: UTMVirtualMachineState = .stopped {
  71. willSet {
  72. onStateChange?()
  73. }
  74. didSet {
  75. delegate?.virtualMachine(self, didTransitionToState: state)
  76. }
  77. }
  78. var screenshot: UTMVirtualMachineScreenshot? {
  79. willSet {
  80. onStateChange?()
  81. }
  82. }
  83. private(set) var snapshotUnsupportedError: Error?
  84. private var isScopedAccess: Bool = false
  85. private weak var screenshotTimer: Timer?
  86. /// Handle SPICE IO related events
  87. weak var ioServiceDelegate: UTMSpiceIODelegate? {
  88. didSet {
  89. if let ioService = ioService {
  90. ioService.delegate = ioServiceDelegate
  91. }
  92. }
  93. }
  94. /// SPICE interface
  95. private(set) var ioService: UTMSpiceIO? {
  96. didSet {
  97. oldValue?.delegate = nil
  98. ioService?.delegate = ioServiceDelegate
  99. }
  100. }
  101. /// Pipe interface (alternative to UTMSpiceIO)
  102. private var pipeInterface: UTMPipeInterface?
  103. private let qemuVM = QEMUVirtualMachine()
  104. /// QEMU Process interface
  105. var system: UTMQemuSystem? {
  106. get async {
  107. await qemuVM.launcher as? UTMQemuSystem
  108. }
  109. }
  110. /// QEMU QMP interface
  111. var monitor: QEMUMonitor? {
  112. get async {
  113. await qemuVM.monitor
  114. }
  115. }
  116. /// QEMU Guest Agent interface
  117. var guestAgent: QEMUGuestAgent? {
  118. get async {
  119. await qemuVM.guestAgent
  120. }
  121. }
  122. private var startTask: Task<Void, any Error>?
  123. private var swtpm: UTMSWTPM?
  124. private var changeCursorRequestInProgress: Bool = false
  125. private static var resourceCacheOperationQueue = DispatchQueue(label: "Resource Cache Operation")
  126. private static var isResourceCacheUpdated = false
  127. @Setting("UseFileLock") private var isUseFileLock = true
  128. #if WITH_SERVER
  129. @Setting("ServerPort") private var serverPort: Int = 0
  130. private var spicePort: SwiftPortmap.Port?
  131. private(set) var spiceServerInfo: UTMRemoteMessageServer.StartVirtualMachine.ServerInformation?
  132. #endif
  133. @MainActor required init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool = false) throws {
  134. self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
  135. // load configuration
  136. self.config = configuration
  137. self.pathUrl = packageUrl
  138. self.isShortcut = isShortcut
  139. self.registryEntry = UTMRegistryEntry.empty
  140. self.registryEntry = loadRegistry()
  141. self.screenshot = loadScreenshot()
  142. }
  143. deinit {
  144. if isScopedAccess {
  145. pathUrl.stopAccessingSecurityScopedResource()
  146. }
  147. }
  148. @MainActor func reload(from packageUrl: URL?) throws {
  149. let packageUrl = packageUrl ?? pathUrl
  150. guard let qemuConfig = try UTMQemuConfiguration.load(from: packageUrl) as? UTMQemuConfiguration else {
  151. throw UTMConfigurationError.invalidBackend
  152. }
  153. config = qemuConfig
  154. pathUrl = packageUrl
  155. updateConfigFromRegistry()
  156. }
  157. }
  158. // MARK: - Shortcut access
  159. extension UTMQemuVirtualMachine {
  160. func accessShortcut() async throws {
  161. guard isShortcut else {
  162. return
  163. }
  164. // if VM has not started yet, we create a temporary process
  165. let system = await system ?? UTMProcess()
  166. var bookmark = await registryEntry.package.remoteBookmark
  167. let existing = bookmark != nil
  168. if !existing {
  169. // create temporary bookmark
  170. bookmark = try pathUrl.bookmarkData()
  171. } else {
  172. let bookmarkPath = await registryEntry.package.path
  173. // in case old path is still accessed
  174. system.stopAccessingPath(bookmarkPath)
  175. }
  176. let (success, newBookmark, newPath) = await system.accessData(withBookmark: bookmark!, securityScoped: existing)
  177. if success {
  178. await registryEntry.setPackageRemoteBookmark(newBookmark, path: newPath)
  179. } else if existing {
  180. // the remote bookmark is invalid but the local one still might be valid
  181. await registryEntry.setPackageRemoteBookmark(nil)
  182. try await accessShortcut()
  183. } else {
  184. throw UTMQemuVirtualMachineError.failedToAccessShortcut
  185. }
  186. }
  187. }
  188. // MARK: - VM actions
  189. extension UTMQemuVirtualMachine {
  190. private var rendererBackend: UTMQEMURendererBackend {
  191. let rawValue = UserDefaults.standard.integer(forKey: "QEMURendererBackend")
  192. return UTMQEMURendererBackend(rawValue: rawValue) ?? .qemuRendererBackendDefault
  193. }
  194. @MainActor private func qemuEnsureEfiVarsAvailable() async throws {
  195. guard let efiVarsURL = config.qemu.efiVarsURL else {
  196. return
  197. }
  198. if !FileManager.default.fileExists(atPath: efiVarsURL.path) {
  199. config.qemu.isUefiVariableResetRequested = true
  200. config.qemu.hasPreloadedSecureBootKeys = config.qemu.hasTPMDevice
  201. _ = try await config.qemu.saveData(to: efiVarsURL.deletingLastPathComponent(), for: config.system)
  202. }
  203. }
  204. private func determineSnapshotSupport() async -> Error? {
  205. // predetermined reasons
  206. if isRunningAsDisposible {
  207. return UTMQemuVirtualMachineError.qemuError(NSLocalizedString("Suspend state cannot be saved when running in disposible mode.", comment: "UTMQemuVirtualMachine"))
  208. }
  209. #if arch(x86_64)
  210. let hasHypervisor = await config.qemu.hasHypervisor
  211. let architecture = await config.system.architecture
  212. if hasHypervisor && architecture == .x86_64 {
  213. return UTMQemuVirtualMachineError.qemuError(NSLocalizedString("Suspend is not supported for virtualization.", comment: "UTMQemuVirtualMachine"))
  214. }
  215. #endif
  216. for display in await config.displays {
  217. if display.hardware.rawValue.contains("-gl-") || display.hardware.rawValue.hasSuffix("-gl") {
  218. return UTMQemuVirtualMachineError.qemuError(NSLocalizedString("Suspend is not supported when GPU acceleration is enabled.", comment: "UTMQemuVirtualMachine"))
  219. }
  220. }
  221. for drive in await config.drives {
  222. if drive.interface == .nvme {
  223. return UTMQemuVirtualMachineError.qemuError(NSLocalizedString("Suspend is not supported when an emulated NVMe device is active.", comment: "UTMQemuVirtualMachine"))
  224. }
  225. }
  226. return nil
  227. }
  228. private func _start(options: UTMVirtualMachineStartOptions) async throws {
  229. // check if we can actually start this VM
  230. guard await isSupported else {
  231. throw UTMQemuVirtualMachineError.emulationNotSupported
  232. }
  233. // create QEMU resource cache if needed
  234. try await ensureQemuResourceCacheUpToDate()
  235. let hasDebugLog = await config.qemu.hasDebugLog
  236. // start logging
  237. if hasDebugLog, let debugLogURL = await config.qemu.debugLogURL {
  238. await qemuVM.setRedirectLog(url: debugLogURL)
  239. } else {
  240. await qemuVM.setRedirectLog(url: nil)
  241. }
  242. let isRunningAsDisposible = options.contains(.bootDisposibleMode)
  243. let isRemoteSession = options.contains(.remoteSession)
  244. #if WITH_SERVER
  245. let spicePassword = isRemoteSession ? String.random(length: 32) : nil
  246. let spicePort = isRemoteSession ? try SwiftPortmap.Port.TCP(unusedPortStartingAt: UInt16(serverPort)) : nil
  247. #else
  248. if isRemoteSession {
  249. throw UTMVirtualMachineError.notImplemented
  250. }
  251. #endif
  252. await MainActor.run {
  253. config.qemu.isDisposable = isRunningAsDisposible
  254. #if WITH_SERVER
  255. config.qemu.spiceServerPort = spicePort?.internalPort
  256. config.qemu.spiceServerPassword = spicePassword
  257. config.qemu.isSpiceServerTlsEnabled = true
  258. #endif
  259. }
  260. // start TPM
  261. if await config.qemu.hasTPMDevice {
  262. let swtpm = UTMSWTPM()
  263. swtpm.ctrlSocketUrl = await config.swtpmSocketURL
  264. swtpm.dataUrl = await config.qemu.tpmDataURL
  265. swtpm.currentDirectoryUrl = await config.socketURL
  266. try await swtpm.start()
  267. self.swtpm = swtpm
  268. }
  269. let allArguments = await config.allArguments
  270. let arguments = allArguments.map({ $0.string })
  271. let resources = allArguments.compactMap({ $0.fileUrls }).flatMap({ $0 })
  272. let remoteBookmarks = await remoteBookmarks
  273. let system = await UTMQemuSystem(arguments: arguments, architecture: config.system.architecture.rawValue)
  274. system.resources = resources
  275. system.currentDirectoryUrl = await config.socketURL
  276. system.remoteBookmarks = remoteBookmarks
  277. system.rendererBackend = rendererBackend
  278. #if os(macOS) // FIXME: verbose logging is broken on iOS
  279. system.hasDebugLog = hasDebugLog
  280. #endif
  281. try Task.checkCancellation()
  282. if isShortcut {
  283. try await accessShortcut()
  284. try Task.checkCancellation()
  285. }
  286. var options = UTMSpiceIOOptions()
  287. if await !config.sound.isEmpty {
  288. options.insert(.hasAudio)
  289. }
  290. if await config.sharing.hasClipboardSharing {
  291. options.insert(.hasClipboardSharing)
  292. }
  293. if await config.sharing.isDirectoryShareReadOnly {
  294. options.insert(.isShareReadOnly)
  295. }
  296. #if os(macOS) // FIXME: verbose logging is broken on iOS
  297. if hasDebugLog {
  298. options.insert(.hasDebugLog)
  299. }
  300. #endif
  301. let spiceSocketUrl = await config.spiceSocketURL
  302. let interface: any QEMUInterface
  303. let spicePublicKey: Data?
  304. if isRemoteSession {
  305. let pipeInterface = UTMPipeInterface()
  306. await MainActor.run {
  307. pipeInterface.monitorInPipeURL = config.monitorPipeURL.appendingPathExtension("in")
  308. pipeInterface.monitorOutPipeURL = config.monitorPipeURL.appendingPathExtension("out")
  309. pipeInterface.guestAgentInPipeURL = config.guestAgentPipeURL.appendingPathExtension("in")
  310. pipeInterface.guestAgentOutPipeURL = config.guestAgentPipeURL.appendingPathExtension("out")
  311. }
  312. try pipeInterface.start()
  313. interface = pipeInterface
  314. // generate a TLS key for this session
  315. guard let key = GenerateRSACertificate("UTM Remote SPICE Server" as CFString,
  316. "UTM" as CFString,
  317. Int.random(in: 1..<CLong.max) as CFNumber,
  318. 1 as CFNumber,
  319. false as CFBoolean)?.takeUnretainedValue() as? [Data] else {
  320. throw UTMQemuVirtualMachineError.keyGenerationFailed
  321. }
  322. try await key[1].write(to: config.spiceTlsKeyUrl)
  323. try await key[2].write(to: config.spiceTlsCertUrl)
  324. spicePublicKey = key[3]
  325. } else {
  326. let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
  327. ioService.logHandler = { [weak system] (line: String) -> Void in
  328. guard !line.contains("spice_make_scancode") else {
  329. return // do not log key presses for privacy reasons
  330. }
  331. system?.logging?.writeLine(line)
  332. }
  333. try ioService.start()
  334. interface = ioService
  335. spicePublicKey = nil
  336. }
  337. try Task.checkCancellation()
  338. // create EFI variables for legacy config as well as handle UEFI resets
  339. try await qemuEnsureEfiVarsAvailable()
  340. try Task.checkCancellation()
  341. // start QEMU
  342. await qemuVM.setDelegate(self)
  343. try await qemuVM.start(launcher: system, interface: interface)
  344. let monitor = await monitor!
  345. try Task.checkCancellation()
  346. // load saved state if requested
  347. let isSuspended = await registryEntry.isSuspended
  348. if !isRunningAsDisposible && isSuspended {
  349. try await monitor.qemuRestoreSnapshot(kSuspendSnapshotName)
  350. try Task.checkCancellation()
  351. }
  352. // set up SPICE sharing and removable drives
  353. try await self.restoreExternalDrives(withMounting: !isSuspended)
  354. if let ioService = interface as? UTMSpiceIO {
  355. try await self.restoreSharedDirectory(for: ioService)
  356. } else {
  357. // TODO: implement shared directory in remote interface
  358. }
  359. try Task.checkCancellation()
  360. // continue VM boot
  361. try await monitor.continueBoot()
  362. // delete saved state
  363. if isSuspended {
  364. try? await deleteSnapshot()
  365. }
  366. // save ioService and let it set the delegate
  367. self.ioService = interface as? UTMSpiceIO
  368. self.pipeInterface = interface as? UTMPipeInterface
  369. self.isRunningAsDisposible = isRunningAsDisposible
  370. // test out snapshots
  371. self.snapshotUnsupportedError = await determineSnapshotSupport()
  372. #if WITH_SERVER
  373. // save server details
  374. if let spicePort = spicePort, let spicePublicKey = spicePublicKey, let spicePassword = spicePassword {
  375. self.spiceServerInfo = .init(spicePortInternal: spicePort.internalPort,
  376. spicePortExternal: try? await spicePort.externalPort,
  377. spiceHostExternal: try? await spicePort.externalIpv4Address,
  378. spicePublicKey: spicePublicKey,
  379. spicePassword: spicePassword)
  380. self.spicePort = spicePort
  381. }
  382. #endif
  383. // update timestamp
  384. if !isRunningAsDisposible {
  385. try? updateLastModified()
  386. }
  387. }
  388. func start(options: UTMVirtualMachineStartOptions = []) async throws {
  389. guard state == .stopped else {
  390. throw UTMQemuVirtualMachineError.invalidVmState
  391. }
  392. state = .starting
  393. do {
  394. startTask = Task {
  395. try await _start(options: options)
  396. }
  397. defer {
  398. startTask = nil
  399. }
  400. try await startTask!.value
  401. state = .started
  402. if screenshotTimer == nil && !options.contains(.remoteSession) {
  403. screenshotTimer = startScreenshotTimer()
  404. }
  405. } catch {
  406. // delete suspend state on error
  407. await registryEntry.setIsSuspended(false)
  408. await qemuVM.kill()
  409. state = .stopped
  410. throw error
  411. }
  412. }
  413. func stop(usingMethod method: UTMVirtualMachineStopMethod) async throws {
  414. if method == .request {
  415. guard let monitor = await monitor else {
  416. throw UTMQemuVirtualMachineError.invalidVmState
  417. }
  418. try await monitor.qemuPowerDown()
  419. return
  420. }
  421. let kill = method == .kill
  422. if kill {
  423. // prevent deadlock force stopping during startup
  424. ioService?.disconnect()
  425. }
  426. guard state != .stopped else {
  427. return // nothing to do
  428. }
  429. guard kill || state == .started || state == .paused else {
  430. throw UTMQemuVirtualMachineError.invalidVmState
  431. }
  432. if !kill {
  433. state = .stopping
  434. }
  435. if kill {
  436. await qemuVM.kill()
  437. } else {
  438. try await qemuVM.stop()
  439. }
  440. isRunningAsDisposible = false
  441. }
  442. private func _restart() async throws {
  443. if await registryEntry.isSuspended {
  444. try? await deleteSnapshot()
  445. }
  446. guard let monitor = await qemuVM.monitor else {
  447. throw UTMQemuVirtualMachineError.invalidVmState
  448. }
  449. try await monitor.qemuReset()
  450. }
  451. func restart() async throws {
  452. guard state == .started || state == .paused else {
  453. throw UTMQemuVirtualMachineError.invalidVmState
  454. }
  455. state = .stopping
  456. do {
  457. try await _restart()
  458. state = .started
  459. } catch {
  460. state = .stopped
  461. throw error
  462. }
  463. }
  464. private func _pause() async throws {
  465. guard let monitor = await monitor else {
  466. throw UTMQemuVirtualMachineError.invalidVmState
  467. }
  468. if isScreenshotEnabled {
  469. await takeScreenshot()
  470. }
  471. try await monitor.qemuStop()
  472. }
  473. func pause() async throws {
  474. guard state == .started else {
  475. throw UTMQemuVirtualMachineError.invalidVmState
  476. }
  477. state = .pausing
  478. do {
  479. try await _pause()
  480. state = .paused
  481. } catch {
  482. state = .stopped
  483. throw error
  484. }
  485. }
  486. private func _saveSnapshot(name: String) async throws {
  487. guard let monitor = await monitor else {
  488. throw UTMQemuVirtualMachineError.invalidVmState
  489. }
  490. let result = try await monitor.qemuSaveSnapshot(name)
  491. if result.localizedCaseInsensitiveContains("Error") {
  492. throw UTMQemuVirtualMachineError.qemuError(result)
  493. }
  494. try? updateLastModified()
  495. }
  496. func saveSnapshot(name: String? = nil) async throws {
  497. guard state == .paused || state == .started else {
  498. throw UTMQemuVirtualMachineError.invalidVmState
  499. }
  500. if let snapshotUnsupportedError = snapshotUnsupportedError {
  501. throw UTMQemuVirtualMachineError.saveSnapshotFailed(snapshotUnsupportedError)
  502. }
  503. let prev = state
  504. state = .saving
  505. defer {
  506. state = prev
  507. }
  508. do {
  509. try await _saveSnapshot(name: name ?? kSuspendSnapshotName)
  510. if name == nil {
  511. await registryEntry.setIsSuspended(true)
  512. try saveScreenshot()
  513. }
  514. } catch {
  515. throw UTMQemuVirtualMachineError.saveSnapshotFailed(error)
  516. }
  517. }
  518. private func _deleteSnapshot(name: String) async throws {
  519. if let monitor = await monitor { // if QEMU is running
  520. let result = try await monitor.qemuDeleteSnapshot(name)
  521. if result.localizedCaseInsensitiveContains("Error") {
  522. throw UTMQemuVirtualMachineError.qemuError(result)
  523. }
  524. try? updateLastModified()
  525. }
  526. }
  527. func deleteSnapshot(name: String? = nil) async throws {
  528. if name == nil {
  529. await registryEntry.setIsSuspended(false)
  530. }
  531. try await _deleteSnapshot(name: name ?? kSuspendSnapshotName)
  532. }
  533. private func _resume() async throws {
  534. guard let monitor = await monitor else {
  535. throw UTMQemuVirtualMachineError.invalidVmState
  536. }
  537. try await monitor.qemuResume()
  538. if await registryEntry.isSuspended {
  539. try? await deleteSnapshot()
  540. }
  541. }
  542. func resume() async throws {
  543. guard state == .paused else {
  544. throw UTMQemuVirtualMachineError.invalidVmState
  545. }
  546. state = .resuming
  547. do {
  548. try await _resume()
  549. state = .started
  550. } catch {
  551. state = .stopped
  552. throw error
  553. }
  554. }
  555. private func _restoreSnapshot(name: String) async throws {
  556. guard let monitor = await monitor else {
  557. throw UTMQemuVirtualMachineError.invalidVmState
  558. }
  559. let result = try await monitor.qemuRestoreSnapshot(name)
  560. if result.localizedCaseInsensitiveContains("Error") {
  561. throw UTMQemuVirtualMachineError.qemuError(result)
  562. }
  563. }
  564. func restoreSnapshot(name: String? = nil) async throws {
  565. guard state == .paused || state == .started else {
  566. throw UTMQemuVirtualMachineError.invalidVmState
  567. }
  568. let prev = state
  569. state = .restoring
  570. do {
  571. try await _restoreSnapshot(name: name ?? kSuspendSnapshotName)
  572. state = prev
  573. } catch {
  574. state = .stopped
  575. throw error
  576. }
  577. }
  578. /// Attempt to cancel the current operation
  579. ///
  580. /// Currently only `vmStart()` can be cancelled.
  581. func cancelOperation() {
  582. startTask?.cancel()
  583. }
  584. }
  585. // MARK: - VM delegate
  586. extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
  587. func qemuVMDidStart(_ qemuVM: QEMUVirtualMachine) {
  588. // not used
  589. }
  590. func qemuVMWillStop(_ qemuVM: QEMUVirtualMachine) {
  591. // not used
  592. }
  593. func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) {
  594. #if WITH_SERVER
  595. spicePort = nil
  596. spiceServerInfo = nil
  597. #endif
  598. swtpm?.stop()
  599. swtpm = nil
  600. ioService = nil
  601. ioServiceDelegate = nil
  602. pipeInterface?.disconnect()
  603. pipeInterface = nil
  604. snapshotUnsupportedError = nil
  605. try? saveScreenshot()
  606. state = .stopped
  607. }
  608. func qemuVM(_ qemuVM: QEMUVirtualMachine, didError error: Error) {
  609. delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
  610. }
  611. func qemuVM(_ qemuVM: QEMUVirtualMachine, didCreatePttyDevice path: String, label: String) {
  612. let scanner = Scanner(string: label)
  613. guard scanner.scanString("term") != nil else {
  614. logger.error("Invalid terminal device '\(label)'")
  615. return
  616. }
  617. var term: Int = -1
  618. guard scanner.scanInt(&term) else {
  619. logger.error("Cannot get index from terminal device '\(label)'")
  620. return
  621. }
  622. let index = term
  623. Task { @MainActor in
  624. guard index >= 0 && index < config.serials.count else {
  625. logger.error("Serial device '\(path)' out of bounds for index \(index)")
  626. return
  627. }
  628. config.serials[index].pttyDevice = URL(fileURLWithPath: path)
  629. }
  630. }
  631. }
  632. // MARK: - Input device switching
  633. extension UTMQemuVirtualMachine {
  634. func changeInputTablet(_ tablet: Bool) async throws {
  635. defer {
  636. changeCursorRequestInProgress = false
  637. }
  638. guard state == .started else {
  639. return
  640. }
  641. guard let monitor = await monitor else {
  642. return
  643. }
  644. do {
  645. let index = try await monitor.mouseIndex(forAbsolute: tablet)
  646. try await monitor.mouseSelect(index)
  647. ioService?.primaryInput?.requestMouseMode(!tablet)
  648. } catch {
  649. logger.error("Error changing mouse mode: \(error)")
  650. }
  651. }
  652. func requestInputTablet(_ tablet: Bool) {
  653. guard !changeCursorRequestInProgress else {
  654. return
  655. }
  656. changeCursorRequestInProgress = true
  657. Task {
  658. defer {
  659. changeCursorRequestInProgress = false
  660. }
  661. try await changeInputTablet(tablet)
  662. }
  663. }
  664. }
  665. // MARK: - Architecture supported
  666. extension UTMQemuVirtualMachine {
  667. /// Check if a QEMU target is supported
  668. /// - Parameter systemArchitecture: QEMU architecture
  669. /// - Returns: true if UTM is compiled with the supporting binaries
  670. internal static func isSupported(systemArchitecture: QEMUArchitecture) -> Bool {
  671. let arch = systemArchitecture.rawValue
  672. let bundleURL = Bundle.main.bundleURL
  673. #if os(macOS)
  674. let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true)
  675. let base = "Versions/A/"
  676. #else
  677. let contentsURL = bundleURL
  678. let base = ""
  679. #endif
  680. let frameworksURL = contentsURL.appendingPathComponent("Frameworks", isDirectory: true)
  681. let framework = frameworksURL.appendingPathComponent("qemu-" + arch + "-softmmu.framework/" + base + "qemu-" + arch + "-softmmu", isDirectory: false)
  682. return FileManager.default.fileExists(atPath: framework.path)
  683. }
  684. /// Check if the current VM target is supported by the host
  685. @MainActor var isSupported: Bool {
  686. return UTMQemuVirtualMachine.isSupported(systemArchitecture: config.system.architecture)
  687. }
  688. }
  689. // MARK: - External drives
  690. extension UTMQemuVirtualMachine {
  691. func eject(_ drive: UTMQemuConfigurationDrive) async throws {
  692. try await eject(drive, isForced: false)
  693. }
  694. private func eject(_ drive: UTMQemuConfigurationDrive, isForced: Bool) async throws {
  695. guard drive.isExternal else {
  696. return
  697. }
  698. if let qemu = await monitor, qemu.isConnected {
  699. try qemu.ejectDrive("drive\(drive.id)", force: isForced)
  700. }
  701. if let oldPath = await registryEntry.externalDrives[drive.id]?.path {
  702. await system?.stopAccessingPath(oldPath)
  703. }
  704. await registryEntry.removeExternalDrive(forId: drive.id)
  705. }
  706. func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL) async throws {
  707. try await changeMedium(drive, to: url, isAccessOnly: false)
  708. }
  709. private func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL, isAccessOnly: Bool) async throws {
  710. let isScopedAccess = url.startAccessingSecurityScopedResource()
  711. defer {
  712. if isScopedAccess {
  713. url.stopAccessingSecurityScopedResource()
  714. }
  715. }
  716. let tempBookmark = try url.bookmarkData()
  717. try await eject(drive, isForced: true)
  718. let file = try UTMRegistryEntry.File(url: url, isReadOnly: drive.isReadOnly)
  719. await registryEntry.setExternalDrive(file, forId: drive.id)
  720. try await changeMedium(drive, with: tempBookmark, isSecurityScoped: false, isAccessOnly: isAccessOnly)
  721. }
  722. private func changeMedium(_ drive: UTMQemuConfigurationDrive, with bookmark: Data, isSecurityScoped: Bool, isAccessOnly: Bool) async throws {
  723. let system = await system ?? UTMProcess()
  724. let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
  725. guard let bookmark = bookmark, let path = path, success else {
  726. throw UTMQemuVirtualMachineError.accessDriveImageFailed
  727. }
  728. await registryEntry.updateExternalDriveRemoteBookmark(bookmark, forId: drive.id)
  729. if let qemu = await monitor, qemu.isConnected && !isAccessOnly {
  730. let isLocked = isUseFileLock && !drive.isReadOnly
  731. try qemu.changeMedium(forDrive: "drive\(drive.id)", path: path, locking: isLocked)
  732. }
  733. }
  734. private func restoreExternalDrives(withMounting isMounting: Bool) async throws {
  735. guard await system != nil else {
  736. throw UTMQemuVirtualMachineError.invalidVmState
  737. }
  738. for drive in await config.drives {
  739. if !drive.isExternal {
  740. continue
  741. }
  742. let id = drive.id
  743. if let bookmark = await registryEntry.externalDrives[id]?.remoteBookmark {
  744. // an image bookmark was saved while QEMU was running
  745. try await changeMedium(drive, with: bookmark, isSecurityScoped: true, isAccessOnly: !isMounting)
  746. } else if let localBookmark = await registryEntry.externalDrives[id]?.bookmark {
  747. // an image bookmark was saved while QEMU was NOT running
  748. let url = try URL(resolvingPersistentBookmarkData: localBookmark)
  749. try await changeMedium(drive, to: url, isAccessOnly: !isMounting)
  750. } else if isMounting && (drive.imageType == .cd || drive.imageType == .disk) {
  751. // a placeholder image might have been mounted
  752. try await eject(drive)
  753. }
  754. }
  755. }
  756. }
  757. // MARK: - Shared directory
  758. extension UTMQemuVirtualMachine {
  759. func stopAccessingPath(_ path: String) async {
  760. await system?.stopAccessingPath(path)
  761. }
  762. func changeVirtfsSharedDirectory(with bookmark: Data, isSecurityScoped: Bool) async throws {
  763. let system = await system ?? UTMProcess()
  764. let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
  765. guard let bookmark = bookmark, let _ = path, success else {
  766. throw UTMQemuVirtualMachineError.accessDriveImageFailed
  767. }
  768. await registryEntry.updateSingleSharedDirectoryRemoteBookmark(bookmark)
  769. }
  770. }
  771. // MARK: - Registry syncing
  772. extension UTMQemuVirtualMachine {
  773. @MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyingEntry entry: UTMRegistryEntry? = nil) {
  774. config.information.uuid = uuid
  775. if let name = name {
  776. config.information.name = name
  777. }
  778. registryEntry = UTMRegistry.shared.entry(for: self)
  779. if let entry = entry {
  780. registryEntry.update(copying: entry)
  781. }
  782. }
  783. @MainActor var remoteBookmarks: [URL: Data] {
  784. var dict = [URL: Data]()
  785. for file in registryEntry.externalDrives.values {
  786. if let bookmark = file.remoteBookmark {
  787. dict[file.url] = bookmark
  788. }
  789. }
  790. for file in registryEntry.sharedDirectories {
  791. if let bookmark = file.remoteBookmark {
  792. dict[file.url] = bookmark
  793. }
  794. }
  795. return dict
  796. }
  797. }
  798. // MARK: - Caching QEMU resources
  799. extension UTMQemuVirtualMachine {
  800. private func _ensureQemuResourceCacheUpToDate() throws {
  801. let fm = FileManager.default
  802. let qemuResourceUrl = Bundle.main.url(forResource: "qemu", withExtension: nil)!
  803. let cacheUrl = try fm.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
  804. let qemuCacheUrl = cacheUrl.appendingPathComponent("qemu", isDirectory: true)
  805. guard fm.fileExists(atPath: qemuCacheUrl.path) else {
  806. try fm.copyItem(at: qemuResourceUrl, to: qemuCacheUrl)
  807. return
  808. }
  809. logger.info("Updating QEMU resource cache...")
  810. // first visit all the subdirectories and create them if needed
  811. let subdirectoryEnumerator = fm.enumerator(at: qemuResourceUrl, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles, .producesRelativePathURLs, .includesDirectoriesPostOrder])!
  812. for case let directoryURL as URL in subdirectoryEnumerator {
  813. guard subdirectoryEnumerator.isEnumeratingDirectoryPostOrder else {
  814. continue
  815. }
  816. let relativePath = directoryURL.relativePath
  817. let destUrl = qemuCacheUrl.appendingPathComponent(relativePath)
  818. var isDirectory: ObjCBool = false
  819. if fm.fileExists(atPath: destUrl.path, isDirectory: &isDirectory) {
  820. // old file is now a directory
  821. if !isDirectory.boolValue {
  822. logger.info("Removing file \(destUrl.path)")
  823. try fm.removeItem(at: destUrl)
  824. } else {
  825. continue
  826. }
  827. }
  828. logger.info("Creating directory \(destUrl.path)")
  829. try fm.createDirectory(at: destUrl, withIntermediateDirectories: true)
  830. }
  831. // next check all the files
  832. let fileEnumerator = fm.enumerator(at: qemuResourceUrl, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey, .isDirectoryKey], options: [.skipsHiddenFiles, .producesRelativePathURLs])!
  833. for case let sourceUrl as URL in fileEnumerator {
  834. let relativePath = sourceUrl.relativePath
  835. let sourceResourceValues = try sourceUrl.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey, .isDirectoryKey])
  836. guard !sourceResourceValues.isDirectory! else {
  837. continue
  838. }
  839. let destUrl = qemuCacheUrl.appendingPathComponent(relativePath)
  840. if fm.fileExists(atPath: destUrl.path) {
  841. // first do a quick comparsion with resource keys
  842. let destResourceValues = try destUrl.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey, .isDirectoryKey])
  843. // old directory is now a file
  844. if destResourceValues.isDirectory! {
  845. logger.info("Removing directory \(destUrl.path)")
  846. try fm.removeItem(at: destUrl)
  847. } else if destResourceValues.contentModificationDate == sourceResourceValues.contentModificationDate && destResourceValues.fileSize == sourceResourceValues.fileSize {
  848. // assume the file is the same
  849. continue
  850. } else {
  851. logger.info("Removing file \(destUrl.path)")
  852. try fm.removeItem(at: destUrl)
  853. }
  854. }
  855. // if we are here, the file has changed
  856. logger.info("Copying file \(sourceUrl.path) to \(destUrl.path)")
  857. try fm.copyItem(at: sourceUrl, to: destUrl)
  858. }
  859. }
  860. func ensureQemuResourceCacheUpToDate() async throws {
  861. guard !Self.isResourceCacheUpdated else {
  862. return
  863. }
  864. try await withCheckedThrowingContinuation { continuation in
  865. Self.resourceCacheOperationQueue.async { [weak self] in
  866. do {
  867. if !Self.isResourceCacheUpdated {
  868. try self?._ensureQemuResourceCacheUpToDate()
  869. Self.isResourceCacheUpdated = true
  870. }
  871. continuation.resume()
  872. } catch {
  873. continuation.resume(throwing: error)
  874. }
  875. }
  876. }
  877. }
  878. }
  879. // MARK: - Errors
  880. enum UTMQemuVirtualMachineError: Error {
  881. case failedToAccessShortcut
  882. case emulationNotSupported
  883. case qemuError(String)
  884. case accessDriveImageFailed
  885. case accessShareFailed
  886. case invalidVmState
  887. case saveSnapshotFailed(Error)
  888. case keyGenerationFailed
  889. }
  890. extension UTMQemuVirtualMachineError: LocalizedError {
  891. var errorDescription: String? {
  892. switch self {
  893. case .failedToAccessShortcut:
  894. return NSLocalizedString("Failed to access data from shortcut.", comment: "UTMQemuVirtualMachine")
  895. case .emulationNotSupported:
  896. return NSLocalizedString("This build of UTM does not support emulating the architecture of this VM.", comment: "UTMQemuVirtualMachine")
  897. case .qemuError(let message):
  898. return message
  899. case .accessDriveImageFailed: return NSLocalizedString("Failed to access drive image path.", comment: "UTMQemuVirtualMachine")
  900. case .accessShareFailed: return NSLocalizedString("Failed to access shared directory.", comment: "UTMQemuVirtualMachine")
  901. case .invalidVmState: return NSLocalizedString("The virtual machine is in an invalid state.", comment: "UTMQemuVirtualMachine")
  902. case .saveSnapshotFailed(let error):
  903. return String.localizedStringWithFormat(NSLocalizedString("Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@", comment: "UTMQemuVirtualMachine"), error.localizedDescription)
  904. case .keyGenerationFailed:
  905. return NSLocalizedString("Failed to generate TLS key for server.", comment: "UTMQemuVirtualMachine")
  906. }
  907. }
  908. }