UTMQemuVirtualMachine.swift 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908
  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. private var system: UTMQemuSystem? {
  105. get async {
  106. await qemuVM.launcher as? UTMQemuSystem
  107. }
  108. }
  109. /// QEMU QMP interface
  110. var monitor: QEMUMonitor? {
  111. get async {
  112. await qemuVM.monitor
  113. }
  114. }
  115. /// QEMU Guest Agent interface
  116. var guestAgent: QEMUGuestAgent? {
  117. get async {
  118. await qemuVM.guestAgent
  119. }
  120. }
  121. private var startTask: Task<Void, any Error>?
  122. private var swtpm: UTMSWTPM?
  123. private var changeCursorRequestInProgress: Bool = false
  124. #if WITH_SERVER
  125. @Setting("ServerPort") private var serverPort: Int = 0
  126. private var spicePort: SwiftPortmap.Port?
  127. private(set) var spiceServerInfo: UTMRemoteMessageServer.StartVirtualMachine.ServerInformation?
  128. #endif
  129. @MainActor required init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool = false) throws {
  130. self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource()
  131. // load configuration
  132. self.config = configuration
  133. self.pathUrl = packageUrl
  134. self.isShortcut = isShortcut
  135. self.registryEntry = UTMRegistryEntry.empty
  136. self.registryEntry = loadRegistry()
  137. self.screenshot = loadScreenshot()
  138. }
  139. deinit {
  140. if isScopedAccess {
  141. pathUrl.stopAccessingSecurityScopedResource()
  142. }
  143. }
  144. @MainActor func reload(from packageUrl: URL?) throws {
  145. let packageUrl = packageUrl ?? pathUrl
  146. guard let qemuConfig = try UTMQemuConfiguration.load(from: packageUrl) as? UTMQemuConfiguration else {
  147. throw UTMConfigurationError.invalidBackend
  148. }
  149. config = qemuConfig
  150. pathUrl = packageUrl
  151. updateConfigFromRegistry()
  152. }
  153. }
  154. // MARK: - Shortcut access
  155. extension UTMQemuVirtualMachine {
  156. func accessShortcut() async throws {
  157. guard isShortcut else {
  158. return
  159. }
  160. // if VM has not started yet, we create a temporary process
  161. let system = await system ?? UTMProcess()
  162. var bookmark = await registryEntry.package.remoteBookmark
  163. let existing = bookmark != nil
  164. if !existing {
  165. // create temporary bookmark
  166. bookmark = try pathUrl.bookmarkData()
  167. } else {
  168. let bookmarkPath = await registryEntry.package.path
  169. // in case old path is still accessed
  170. system.stopAccessingPath(bookmarkPath)
  171. }
  172. let (success, newBookmark, newPath) = await system.accessData(withBookmark: bookmark!, securityScoped: existing)
  173. if success {
  174. await registryEntry.setPackageRemoteBookmark(newBookmark, path: newPath)
  175. } else if existing {
  176. // the remote bookmark is invalid but the local one still might be valid
  177. await registryEntry.setPackageRemoteBookmark(nil)
  178. try await accessShortcut()
  179. } else {
  180. throw UTMQemuVirtualMachineError.failedToAccessShortcut
  181. }
  182. }
  183. }
  184. // MARK: - VM actions
  185. extension UTMQemuVirtualMachine {
  186. private var rendererBackend: UTMQEMURendererBackend {
  187. let rawValue = UserDefaults.standard.integer(forKey: "QEMURendererBackend")
  188. return UTMQEMURendererBackend(rawValue: rawValue) ?? .qemuRendererBackendDefault
  189. }
  190. @MainActor private func qemuEnsureEfiVarsAvailable() async throws {
  191. guard let efiVarsURL = config.qemu.efiVarsURL else {
  192. return
  193. }
  194. var doesVarsExist = FileManager.default.fileExists(atPath: efiVarsURL.path)
  195. if config.qemu.isUefiVariableResetRequested {
  196. if doesVarsExist {
  197. try FileManager.default.removeItem(at: efiVarsURL)
  198. doesVarsExist = false
  199. }
  200. config.qemu.isUefiVariableResetRequested = false
  201. }
  202. if !doesVarsExist {
  203. _ = try await config.qemu.saveData(to: efiVarsURL.deletingLastPathComponent(), for: config.system)
  204. }
  205. }
  206. private func determineSnapshotSupport() async -> Error? {
  207. // predetermined reasons
  208. if isRunningAsDisposible {
  209. return UTMQemuVirtualMachineError.qemuError(NSLocalizedString("Suspend state cannot be saved when running in disposible mode.", comment: "UTMQemuVirtualMachine"))
  210. }
  211. #if arch(x86_64)
  212. let hasHypervisor = await config.qemu.hasHypervisor
  213. let architecture = await config.system.architecture
  214. if hasHypervisor && architecture == .x86_64 {
  215. return UTMQemuVirtualMachineError.qemuError(NSLocalizedString("Suspend is not supported for virtualization.", comment: "UTMQemuVirtualMachine"))
  216. }
  217. #endif
  218. for display in await config.displays {
  219. if display.hardware.rawValue.contains("-gl-") || display.hardware.rawValue.hasSuffix("-gl") {
  220. return UTMQemuVirtualMachineError.qemuError(NSLocalizedString("Suspend is not supported when GPU acceleration is enabled.", comment: "UTMQemuVirtualMachine"))
  221. }
  222. }
  223. for drive in await config.drives {
  224. if drive.interface == .nvme {
  225. return UTMQemuVirtualMachineError.qemuError(NSLocalizedString("Suspend is not supported when an emulated NVMe device is active.", comment: "UTMQemuVirtualMachine"))
  226. }
  227. }
  228. return nil
  229. }
  230. private func _start(options: UTMVirtualMachineStartOptions) async throws {
  231. // check if we can actually start this VM
  232. guard await isSupported else {
  233. throw UTMQemuVirtualMachineError.emulationNotSupported
  234. }
  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. }
  384. func start(options: UTMVirtualMachineStartOptions = []) async throws {
  385. guard state == .stopped else {
  386. throw UTMQemuVirtualMachineError.invalidVmState
  387. }
  388. state = .starting
  389. do {
  390. startTask = Task {
  391. try await _start(options: options)
  392. }
  393. defer {
  394. startTask = nil
  395. }
  396. try await startTask!.value
  397. state = .started
  398. if screenshotTimer == nil && !options.contains(.remoteSession) {
  399. screenshotTimer = startScreenshotTimer()
  400. }
  401. } catch {
  402. // delete suspend state on error
  403. await registryEntry.setIsSuspended(false)
  404. await qemuVM.kill()
  405. state = .stopped
  406. throw error
  407. }
  408. }
  409. func stop(usingMethod method: UTMVirtualMachineStopMethod) async throws {
  410. if method == .request {
  411. guard let monitor = await monitor else {
  412. throw UTMQemuVirtualMachineError.invalidVmState
  413. }
  414. try await monitor.qemuPowerDown()
  415. return
  416. }
  417. let kill = method == .kill
  418. if kill {
  419. // prevent deadlock force stopping during startup
  420. ioService?.disconnect()
  421. }
  422. guard state != .stopped else {
  423. return // nothing to do
  424. }
  425. guard kill || state == .started || state == .paused else {
  426. throw UTMQemuVirtualMachineError.invalidVmState
  427. }
  428. if !kill {
  429. state = .stopping
  430. }
  431. if kill {
  432. await qemuVM.kill()
  433. } else {
  434. try await qemuVM.stop()
  435. }
  436. isRunningAsDisposible = false
  437. }
  438. private func _restart() async throws {
  439. if await registryEntry.isSuspended {
  440. try? await deleteSnapshot()
  441. }
  442. guard let monitor = await qemuVM.monitor else {
  443. throw UTMQemuVirtualMachineError.invalidVmState
  444. }
  445. try await monitor.qemuReset()
  446. }
  447. func restart() async throws {
  448. guard state == .started || state == .paused else {
  449. throw UTMQemuVirtualMachineError.invalidVmState
  450. }
  451. state = .stopping
  452. do {
  453. try await _restart()
  454. state = .started
  455. } catch {
  456. state = .stopped
  457. throw error
  458. }
  459. }
  460. private func _pause() async throws {
  461. guard let monitor = await monitor else {
  462. throw UTMQemuVirtualMachineError.invalidVmState
  463. }
  464. await takeScreenshot()
  465. try await monitor.qemuStop()
  466. }
  467. func pause() async throws {
  468. guard state == .started else {
  469. throw UTMQemuVirtualMachineError.invalidVmState
  470. }
  471. state = .pausing
  472. do {
  473. try await _pause()
  474. state = .paused
  475. } catch {
  476. state = .stopped
  477. throw error
  478. }
  479. }
  480. private func _saveSnapshot(name: String) async throws {
  481. guard let monitor = await monitor else {
  482. throw UTMQemuVirtualMachineError.invalidVmState
  483. }
  484. let result = try await monitor.qemuSaveSnapshot(name)
  485. if result.localizedCaseInsensitiveContains("Error") {
  486. throw UTMQemuVirtualMachineError.qemuError(result)
  487. }
  488. }
  489. func saveSnapshot(name: String? = nil) async throws {
  490. guard state == .paused || state == .started else {
  491. throw UTMQemuVirtualMachineError.invalidVmState
  492. }
  493. if let snapshotUnsupportedError = snapshotUnsupportedError {
  494. throw UTMQemuVirtualMachineError.saveSnapshotFailed(snapshotUnsupportedError)
  495. }
  496. let prev = state
  497. state = .saving
  498. defer {
  499. state = prev
  500. }
  501. do {
  502. try await _saveSnapshot(name: name ?? kSuspendSnapshotName)
  503. if name == nil {
  504. await registryEntry.setIsSuspended(true)
  505. try saveScreenshot()
  506. }
  507. } catch {
  508. throw UTMQemuVirtualMachineError.saveSnapshotFailed(error)
  509. }
  510. }
  511. private func _deleteSnapshot(name: String) async throws {
  512. if let monitor = await monitor { // if QEMU is running
  513. let result = try await monitor.qemuDeleteSnapshot(name)
  514. if result.localizedCaseInsensitiveContains("Error") {
  515. throw UTMQemuVirtualMachineError.qemuError(result)
  516. }
  517. }
  518. }
  519. func deleteSnapshot(name: String? = nil) async throws {
  520. if name == nil {
  521. await registryEntry.setIsSuspended(false)
  522. }
  523. try await _deleteSnapshot(name: name ?? kSuspendSnapshotName)
  524. }
  525. private func _resume() async throws {
  526. guard let monitor = await monitor else {
  527. throw UTMQemuVirtualMachineError.invalidVmState
  528. }
  529. try await monitor.qemuResume()
  530. if await registryEntry.isSuspended {
  531. try? await deleteSnapshot()
  532. }
  533. }
  534. func resume() async throws {
  535. guard state == .paused else {
  536. throw UTMQemuVirtualMachineError.invalidVmState
  537. }
  538. state = .resuming
  539. do {
  540. try await _resume()
  541. state = .started
  542. } catch {
  543. state = .stopped
  544. throw error
  545. }
  546. }
  547. private func _restoreSnapshot(name: String) async throws {
  548. guard let monitor = await monitor else {
  549. throw UTMQemuVirtualMachineError.invalidVmState
  550. }
  551. let result = try await monitor.qemuRestoreSnapshot(name)
  552. if result.localizedCaseInsensitiveContains("Error") {
  553. throw UTMQemuVirtualMachineError.qemuError(result)
  554. }
  555. }
  556. func restoreSnapshot(name: String? = nil) async throws {
  557. guard state == .paused || state == .started else {
  558. throw UTMQemuVirtualMachineError.invalidVmState
  559. }
  560. let prev = state
  561. state = .restoring
  562. do {
  563. try await _restoreSnapshot(name: name ?? kSuspendSnapshotName)
  564. state = prev
  565. } catch {
  566. state = .stopped
  567. throw error
  568. }
  569. }
  570. /// Attempt to cancel the current operation
  571. ///
  572. /// Currently only `vmStart()` can be cancelled.
  573. func cancelOperation() {
  574. startTask?.cancel()
  575. }
  576. }
  577. // MARK: - VM delegate
  578. extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate {
  579. func qemuVMDidStart(_ qemuVM: QEMUVirtualMachine) {
  580. // not used
  581. }
  582. func qemuVMWillStop(_ qemuVM: QEMUVirtualMachine) {
  583. // not used
  584. }
  585. func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) {
  586. #if WITH_SERVER
  587. spicePort = nil
  588. spiceServerInfo = nil
  589. #endif
  590. swtpm?.stop()
  591. swtpm = nil
  592. ioService = nil
  593. ioServiceDelegate = nil
  594. pipeInterface?.disconnect()
  595. pipeInterface = nil
  596. snapshotUnsupportedError = nil
  597. try? saveScreenshot()
  598. state = .stopped
  599. }
  600. func qemuVM(_ qemuVM: QEMUVirtualMachine, didError error: Error) {
  601. delegate?.virtualMachine(self, didErrorWithMessage: error.localizedDescription)
  602. }
  603. func qemuVM(_ qemuVM: QEMUVirtualMachine, didCreatePttyDevice path: String, label: String) {
  604. let scanner = Scanner(string: label)
  605. guard scanner.scanString("term") != nil else {
  606. logger.error("Invalid terminal device '\(label)'")
  607. return
  608. }
  609. var term: Int = -1
  610. guard scanner.scanInt(&term) else {
  611. logger.error("Cannot get index from terminal device '\(label)'")
  612. return
  613. }
  614. let index = term
  615. Task { @MainActor in
  616. guard index >= 0 && index < config.serials.count else {
  617. logger.error("Serial device '\(path)' out of bounds for index \(index)")
  618. return
  619. }
  620. config.serials[index].pttyDevice = URL(fileURLWithPath: path)
  621. }
  622. }
  623. }
  624. // MARK: - Input device switching
  625. extension UTMQemuVirtualMachine {
  626. func changeInputTablet(_ tablet: Bool) async throws {
  627. defer {
  628. changeCursorRequestInProgress = false
  629. }
  630. guard state == .started else {
  631. return
  632. }
  633. guard let monitor = await monitor else {
  634. return
  635. }
  636. do {
  637. let index = try await monitor.mouseIndex(forAbsolute: tablet)
  638. try await monitor.mouseSelect(index)
  639. ioService?.primaryInput?.requestMouseMode(!tablet)
  640. } catch {
  641. logger.error("Error changing mouse mode: \(error)")
  642. }
  643. }
  644. func requestInputTablet(_ tablet: Bool) {
  645. guard !changeCursorRequestInProgress else {
  646. return
  647. }
  648. changeCursorRequestInProgress = true
  649. Task {
  650. defer {
  651. changeCursorRequestInProgress = false
  652. }
  653. try await changeInputTablet(tablet)
  654. }
  655. }
  656. }
  657. // MARK: - Architecture supported
  658. extension UTMQemuVirtualMachine {
  659. /// Check if a QEMU target is supported
  660. /// - Parameter systemArchitecture: QEMU architecture
  661. /// - Returns: true if UTM is compiled with the supporting binaries
  662. internal static func isSupported(systemArchitecture: QEMUArchitecture) -> Bool {
  663. let arch = systemArchitecture.rawValue
  664. let bundleURL = Bundle.main.bundleURL
  665. #if os(macOS)
  666. let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true)
  667. let base = "Versions/A/"
  668. #else
  669. let contentsURL = bundleURL
  670. let base = ""
  671. #endif
  672. let frameworksURL = contentsURL.appendingPathComponent("Frameworks", isDirectory: true)
  673. let framework = frameworksURL.appendingPathComponent("qemu-" + arch + "-softmmu.framework/" + base + "qemu-" + arch + "-softmmu", isDirectory: false)
  674. return FileManager.default.fileExists(atPath: framework.path)
  675. }
  676. /// Check if the current VM target is supported by the host
  677. @MainActor var isSupported: Bool {
  678. return UTMQemuVirtualMachine.isSupported(systemArchitecture: config.system.architecture)
  679. }
  680. }
  681. // MARK: - External drives
  682. extension UTMQemuVirtualMachine {
  683. func eject(_ drive: UTMQemuConfigurationDrive) async throws {
  684. try await eject(drive, isForced: false)
  685. }
  686. private func eject(_ drive: UTMQemuConfigurationDrive, isForced: Bool) async throws {
  687. guard drive.isExternal else {
  688. return
  689. }
  690. if let qemu = await monitor, qemu.isConnected {
  691. try qemu.ejectDrive("drive\(drive.id)", force: isForced)
  692. }
  693. if let oldPath = await registryEntry.externalDrives[drive.id]?.path {
  694. await system?.stopAccessingPath(oldPath)
  695. }
  696. await registryEntry.removeExternalDrive(forId: drive.id)
  697. }
  698. func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL) async throws {
  699. try await changeMedium(drive, to: url, isAccessOnly: false)
  700. }
  701. private func changeMedium(_ drive: UTMQemuConfigurationDrive, to url: URL, isAccessOnly: Bool) async throws {
  702. _ = url.startAccessingSecurityScopedResource()
  703. defer {
  704. url.stopAccessingSecurityScopedResource()
  705. }
  706. let tempBookmark = try url.bookmarkData()
  707. try await eject(drive, isForced: true)
  708. let file = try UTMRegistryEntry.File(url: url, isReadOnly: drive.isReadOnly)
  709. await registryEntry.setExternalDrive(file, forId: drive.id)
  710. try await changeMedium(drive, with: tempBookmark, url: url, isSecurityScoped: false, isAccessOnly: isAccessOnly)
  711. }
  712. private func changeMedium(_ drive: UTMQemuConfigurationDrive, with bookmark: Data, url: URL?, isSecurityScoped: Bool, isAccessOnly: Bool) async throws {
  713. let system = await system ?? UTMProcess()
  714. let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
  715. guard let bookmark = bookmark, let path = path, success else {
  716. throw UTMQemuVirtualMachineError.accessDriveImageFailed
  717. }
  718. await registryEntry.updateExternalDriveRemoteBookmark(bookmark, forId: drive.id)
  719. if let qemu = await monitor, qemu.isConnected && !isAccessOnly {
  720. try qemu.changeMedium(forDrive: "drive\(drive.id)", path: path)
  721. }
  722. }
  723. private func restoreExternalDrives(withMounting isMounting: Bool) async throws {
  724. guard await system != nil else {
  725. throw UTMQemuVirtualMachineError.invalidVmState
  726. }
  727. for drive in await config.drives {
  728. if !drive.isExternal {
  729. continue
  730. }
  731. let id = drive.id
  732. if let bookmark = await registryEntry.externalDrives[id]?.remoteBookmark {
  733. // an image bookmark was saved while QEMU was running
  734. try await changeMedium(drive, with: bookmark, url: nil, isSecurityScoped: true, isAccessOnly: !isMounting)
  735. } else if let localBookmark = await registryEntry.externalDrives[id]?.bookmark {
  736. // an image bookmark was saved while QEMU was NOT running
  737. let url = try URL(resolvingPersistentBookmarkData: localBookmark)
  738. try await changeMedium(drive, to: url, isAccessOnly: !isMounting)
  739. } else if isMounting && (drive.imageType == .cd || drive.imageType == .disk) {
  740. // a placeholder image might have been mounted
  741. try await eject(drive)
  742. }
  743. }
  744. }
  745. }
  746. // MARK: - Shared directory
  747. extension UTMQemuVirtualMachine {
  748. func stopAccessingPath(_ path: String) async {
  749. await system?.stopAccessingPath(path)
  750. }
  751. func changeVirtfsSharedDirectory(with bookmark: Data, isSecurityScoped: Bool) async throws {
  752. let system = await system ?? UTMProcess()
  753. let (success, bookmark, path) = await system.accessData(withBookmark: bookmark, securityScoped: isSecurityScoped)
  754. guard let bookmark = bookmark, let _ = path, success else {
  755. throw UTMQemuVirtualMachineError.accessDriveImageFailed
  756. }
  757. await registryEntry.updateSingleSharedDirectoryRemoteBookmark(bookmark)
  758. }
  759. }
  760. // MARK: - Registry syncing
  761. extension UTMQemuVirtualMachine {
  762. @MainActor func changeUuid(to uuid: UUID, name: String? = nil, copyingEntry entry: UTMRegistryEntry? = nil) {
  763. config.information.uuid = uuid
  764. if let name = name {
  765. config.information.name = name
  766. }
  767. registryEntry = UTMRegistry.shared.entry(for: self)
  768. if let entry = entry {
  769. registryEntry.update(copying: entry)
  770. }
  771. }
  772. @MainActor var remoteBookmarks: [URL: Data] {
  773. var dict = [URL: Data]()
  774. for file in registryEntry.externalDrives.values {
  775. if let bookmark = file.remoteBookmark {
  776. dict[file.url] = bookmark
  777. }
  778. }
  779. for file in registryEntry.sharedDirectories {
  780. if let bookmark = file.remoteBookmark {
  781. dict[file.url] = bookmark
  782. }
  783. }
  784. return dict
  785. }
  786. }
  787. enum UTMQemuVirtualMachineError: Error {
  788. case failedToAccessShortcut
  789. case emulationNotSupported
  790. case qemuError(String)
  791. case accessDriveImageFailed
  792. case accessShareFailed
  793. case invalidVmState
  794. case saveSnapshotFailed(Error)
  795. case keyGenerationFailed
  796. }
  797. extension UTMQemuVirtualMachineError: LocalizedError {
  798. var errorDescription: String? {
  799. switch self {
  800. case .failedToAccessShortcut:
  801. return NSLocalizedString("Failed to access data from shortcut.", comment: "UTMQemuVirtualMachine")
  802. case .emulationNotSupported:
  803. return NSLocalizedString("This build of UTM does not support emulating the architecture of this VM.", comment: "UTMQemuVirtualMachine")
  804. case .qemuError(let message):
  805. return message
  806. case .accessDriveImageFailed: return NSLocalizedString("Failed to access drive image path.", comment: "UTMQemuVirtualMachine")
  807. case .accessShareFailed: return NSLocalizedString("Failed to access shared directory.", comment: "UTMQemuVirtualMachine")
  808. case .invalidVmState: return NSLocalizedString("The virtual machine is in an invalid state.", comment: "UTMQemuVirtualMachine")
  809. case .saveSnapshotFailed(let error):
  810. return String.localizedStringWithFormat(NSLocalizedString("Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@", comment: "UTMQemuVirtualMachine"), error.localizedDescription)
  811. case .keyGenerationFailed:
  812. return NSLocalizedString("Failed to generate TLS key for server.", comment: "UTMQemuVirtualMachine")
  813. }
  814. }
  815. }