VMSessionState.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  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 AVFAudio
  18. import SwiftUI
  19. /// Represents the UI state for a single VM session.
  20. @MainActor class VMSessionState: NSObject, ObservableObject {
  21. struct ID: Hashable, Codable {
  22. private var id = UUID()
  23. }
  24. struct WindowID: Hashable, Codable {
  25. private var id = UUID()
  26. }
  27. struct GlobalWindowID: Hashable, Codable {
  28. private(set) var sessionID: VMSessionState.ID
  29. private(set) var windowID: VMSessionState.WindowID
  30. }
  31. static private(set) var allActiveSessions: [ID: VMSessionState] = [:]
  32. let id: ID = ID()
  33. let vm: any UTMSpiceVirtualMachine
  34. var qemuConfig: UTMQemuConfiguration {
  35. vm.config
  36. }
  37. @Published var vmState: UTMVirtualMachineState = .stopped
  38. @Published var nonfatalError: String?
  39. @Published var fatalError: String?
  40. @Published var primaryInput: CSInput?
  41. #if WITH_USB
  42. private var primaryUsbManager: CSUSBManager?
  43. private var usbManagerQueue = DispatchQueue(label: "USB Manager Queue", qos: .utility)
  44. @Published var mostRecentConnectedDevice: CSUSBDevice?
  45. @Published var allUsbDevices: [CSUSBDevice] = []
  46. @Published var connectedUsbDevices: [CSUSBDevice] = []
  47. #endif
  48. @Published var isUsbBusy: Bool = false
  49. @Published var devices: [VMWindowState.Device] = []
  50. @Published var windows: [GlobalWindowID] = []
  51. @Published var primaryWindow: WindowID?
  52. @Published var activeWindow: WindowID?
  53. @Published var windowDeviceMap: [WindowID: VMWindowState.Device] = [:]
  54. @Published var externalWindowBinding: Binding<VMWindowState>?
  55. @Published var hasShownMemoryWarning: Bool = false
  56. @Published var isDynamicResolutionSupported: Bool = false
  57. private var hasAutosave: Bool = false
  58. private var backgroundTask: UIBackgroundTaskIdentifier?
  59. init(for vm: any UTMSpiceVirtualMachine) {
  60. self.vm = vm
  61. super.init()
  62. vm.delegate = self
  63. vm.ioServiceDelegate = self
  64. }
  65. func newWindow() -> GlobalWindowID {
  66. GlobalWindowID(sessionID: id, windowID: WindowID())
  67. }
  68. func registerWindow(_ window: WindowID, isExternal: Bool = false) {
  69. let globalWindow = GlobalWindowID(sessionID: id, windowID: window)
  70. windows.append(globalWindow)
  71. if !isExternal, primaryWindow == nil {
  72. primaryWindow = window
  73. }
  74. if !isExternal, activeWindow == nil {
  75. activeWindow = window
  76. }
  77. assignDefaultDisplay(for: window, isExternal: isExternal)
  78. }
  79. func removeWindow(_ window: WindowID) {
  80. let globalWindow = GlobalWindowID(sessionID: id, windowID: window)
  81. windows.removeAll { $0 == globalWindow }
  82. if primaryWindow == window {
  83. primaryWindow = windows.first?.windowID
  84. }
  85. if activeWindow == window {
  86. activeWindow = windows.first?.windowID
  87. }
  88. windowDeviceMap.removeValue(forKey: window)
  89. }
  90. private func assignDefaultDisplay(for window: WindowID, isExternal: Bool) {
  91. // default first to next GUI, then to next serial
  92. let filtered = devices.filter {
  93. if case .display(_, _) = $0 {
  94. return true
  95. } else {
  96. return false
  97. }
  98. }
  99. for device in filtered {
  100. if !windowDeviceMap.values.contains(device) {
  101. windowDeviceMap[window] = device
  102. return
  103. }
  104. }
  105. if isExternal {
  106. return // no serial device for external display
  107. }
  108. for device in devices {
  109. if !windowDeviceMap.values.contains(device) {
  110. windowDeviceMap[window] = device
  111. return
  112. }
  113. }
  114. }
  115. }
  116. extension VMSessionState: UTMVirtualMachineDelegate {
  117. nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
  118. Task { @MainActor in
  119. vmState = state
  120. if state == .stopped {
  121. #if WITH_USB
  122. clearDevices()
  123. #endif
  124. }
  125. }
  126. }
  127. nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
  128. Task { @MainActor in
  129. nonfatalError = message
  130. }
  131. }
  132. nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
  133. }
  134. nonisolated func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
  135. }
  136. }
  137. extension VMSessionState: UTMSpiceIODelegate {
  138. nonisolated func spiceDidCreateInput(_ input: CSInput) {
  139. Task { @MainActor in
  140. guard primaryInput == nil else {
  141. return
  142. }
  143. primaryInput = input
  144. }
  145. }
  146. nonisolated func spiceDidDestroyInput(_ input: CSInput) {
  147. Task { @MainActor in
  148. guard primaryInput == input else {
  149. return
  150. }
  151. primaryInput = nil
  152. }
  153. }
  154. nonisolated func spiceDidCreateDisplay(_ display: CSDisplay) {
  155. Task { @MainActor in
  156. assert(display.monitorID < qemuConfig.displays.count)
  157. let device = VMWindowState.Device.display(display, display.monitorID)
  158. devices.append(device)
  159. // associate with the next available window
  160. for window in windows {
  161. let windowId = window.windowID
  162. if windowDeviceMap[windowId] == nil {
  163. if windowId == primaryWindow && !display.isPrimaryDisplay {
  164. // prefer the primary display for the primary window
  165. continue
  166. }
  167. if windowId != primaryWindow && display.isPrimaryDisplay {
  168. // don't assign primary display to non-primary window either
  169. continue
  170. }
  171. windowDeviceMap[windowId] = device
  172. }
  173. }
  174. }
  175. }
  176. nonisolated func spiceDidDestroyDisplay(_ display: CSDisplay) {
  177. Task { @MainActor in
  178. let device = VMWindowState.Device.display(display, display.monitorID)
  179. devices.removeAll { $0 == device }
  180. for window in windows {
  181. let windowId = window.windowID
  182. if windowDeviceMap[windowId] == device {
  183. windowDeviceMap[windowId] = nil
  184. }
  185. }
  186. }
  187. }
  188. nonisolated func spiceDidUpdateDisplay(_ display: CSDisplay) {
  189. // nothing to do
  190. }
  191. nonisolated private func configIdForSerial(_ serial: CSPort) -> Int? {
  192. let prefix = "com.utmapp.terminal."
  193. guard serial.name?.hasPrefix(prefix) ?? false else {
  194. return nil
  195. }
  196. return Int(serial.name!.dropFirst(prefix.count))
  197. }
  198. nonisolated func spiceDidCreateSerial(_ serial: CSPort) {
  199. Task { @MainActor in
  200. guard let id = configIdForSerial(serial) else {
  201. logger.error("cannot setup window for serial '\(serial.name ?? "(null)")'")
  202. return
  203. }
  204. let device = VMWindowState.Device.serial(serial, id)
  205. assert(id < qemuConfig.serials.count)
  206. assert(qemuConfig.serials[id].mode == .builtin && qemuConfig.serials[id].terminal != nil)
  207. devices.append(device)
  208. // associate with the next available window
  209. for window in windows {
  210. let windowId = window.windowID
  211. if windowDeviceMap[windowId] == nil {
  212. if windowId == primaryWindow && !qemuConfig.displays.isEmpty {
  213. // prefer a GUI display over console for primary if both are available
  214. continue
  215. }
  216. if windowId == externalWindowBinding?.wrappedValue.id {
  217. // do not set serial with external display
  218. continue
  219. }
  220. windowDeviceMap[windowId] = device
  221. }
  222. }
  223. }
  224. }
  225. nonisolated func spiceDidDestroySerial(_ serial: CSPort) {
  226. Task { @MainActor in
  227. guard let id = configIdForSerial(serial) else {
  228. return
  229. }
  230. let device = VMWindowState.Device.serial(serial, id)
  231. devices.removeAll { $0 == device }
  232. for window in windows {
  233. let windowId = window.windowID
  234. if windowDeviceMap[windowId] == device {
  235. windowDeviceMap[windowId] = nil
  236. }
  237. }
  238. }
  239. }
  240. #if WITH_USB
  241. nonisolated func spiceDidChangeUsbManager(_ usbManager: CSUSBManager?) {
  242. Task { @MainActor in
  243. primaryUsbManager?.delegate = nil
  244. primaryUsbManager = usbManager
  245. usbManager?.delegate = self
  246. refreshDevices()
  247. }
  248. }
  249. #endif
  250. nonisolated func spiceDynamicResolutionSupportDidChange(_ supported: Bool) {
  251. Task { @MainActor in
  252. isDynamicResolutionSupported = supported
  253. }
  254. }
  255. nonisolated func spiceDidDisconnect() {
  256. Task { @MainActor in
  257. fatalError = NSLocalizedString("Connection to the server was lost.", comment: "VMSessionState")
  258. }
  259. }
  260. }
  261. #if WITH_USB
  262. extension VMSessionState: CSUSBManagerDelegate {
  263. nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceError error: String, for device: CSUSBDevice) {
  264. Task { @MainActor in
  265. nonfatalError = error
  266. refreshDevices()
  267. }
  268. }
  269. nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceAttached device: CSUSBDevice) {
  270. Task { @MainActor in
  271. if vmState == .started {
  272. mostRecentConnectedDevice = device
  273. }
  274. allUsbDevices.append(device)
  275. }
  276. }
  277. nonisolated func spiceUsbManager(_ usbManager: CSUSBManager, deviceRemoved device: CSUSBDevice) {
  278. Task { @MainActor in
  279. connectedUsbDevices.removeAll(where: { $0 == device })
  280. allUsbDevices.removeAll(where: { $0 == device })
  281. }
  282. }
  283. private func withUsbManagerSerialized<T>(_ task: @escaping () async throws -> T, onSuccess: @escaping @MainActor (T) -> Void = { _ in }, onError: @escaping @MainActor (Error) -> Void = { _ in }) {
  284. usbManagerQueue.async {
  285. let event = DispatchSemaphore(value: 0)
  286. Task.detached { [self] in
  287. await MainActor.run {
  288. isUsbBusy = true
  289. }
  290. do {
  291. let result = try await task()
  292. await MainActor.run {
  293. isUsbBusy = false
  294. onSuccess(result)
  295. }
  296. } catch {
  297. await MainActor.run {
  298. isUsbBusy = false
  299. onError(error)
  300. }
  301. }
  302. event.signal()
  303. }
  304. event.wait()
  305. }
  306. }
  307. func refreshDevices() {
  308. guard let usbManager = self.primaryUsbManager else {
  309. logger.error("no usb manager connected")
  310. return
  311. }
  312. withUsbManagerSerialized {
  313. let devices = usbManager.usbDevices
  314. for device in devices {
  315. let name = device.name // cache descriptor read
  316. logger.debug("found device: \(name ?? "(unknown)")")
  317. }
  318. return devices
  319. } onSuccess: { devices in
  320. self.allUsbDevices = devices
  321. }
  322. }
  323. func connectDevice(_ usbDevice: CSUSBDevice) {
  324. guard let usbManager = self.primaryUsbManager else {
  325. logger.error("no usb manager connected")
  326. return
  327. }
  328. guard !connectedUsbDevices.contains(usbDevice) else {
  329. logger.warning("connecting a device that is already connected")
  330. return
  331. }
  332. withUsbManagerSerialized {
  333. try await usbManager.connectUsbDevice(usbDevice)
  334. } onSuccess: {
  335. self.connectedUsbDevices.append(usbDevice)
  336. } onError: { error in
  337. self.nonfatalError = error.localizedDescription
  338. }
  339. }
  340. func disconnectDevice(_ usbDevice: CSUSBDevice) {
  341. guard let usbManager = self.primaryUsbManager else {
  342. logger.error("no usb manager connected")
  343. return
  344. }
  345. guard usbManager.isUsbDeviceConnected(usbDevice) else {
  346. logger.warning("disconnecting a device that is not connected")
  347. return
  348. }
  349. withUsbManagerSerialized {
  350. self.connectedUsbDevices.removeAll(where: { $0 == usbDevice })
  351. try await usbManager.disconnectUsbDevice(usbDevice)
  352. } onError: { error in
  353. self.nonfatalError = error.localizedDescription
  354. }
  355. }
  356. private func clearDevices() {
  357. Task { @MainActor in
  358. connectedUsbDevices.removeAll()
  359. allUsbDevices.removeAll()
  360. }
  361. }
  362. }
  363. #endif
  364. extension VMSessionState {
  365. private var shouldRunInBackground: Bool {
  366. UserDefaults.standard.bool(forKey: "RunInBackground")
  367. }
  368. private var shouldAutosaveBackground: Bool {
  369. UserDefaults.standard.bool(forKey: "AutosaveBackground")
  370. }
  371. func start(options: UTMVirtualMachineStartOptions = []) {
  372. let audioSession = AVAudioSession.sharedInstance()
  373. do {
  374. let preferDeviceMicrophone = UserDefaults.standard.bool(forKey: "PreferDeviceMicrophone")
  375. var options: AVAudioSession.CategoryOptions = [.mixWithOthers, .defaultToSpeaker, .allowBluetoothA2DP, .allowAirPlay]
  376. if !preferDeviceMicrophone {
  377. options.insert(.allowBluetooth)
  378. }
  379. try audioSession.setCategory(.playAndRecord, options: options)
  380. try audioSession.setActive(true)
  381. } catch {
  382. logger.warning("Error starting audio session: \(error.localizedDescription)")
  383. }
  384. Self.allActiveSessions[id] = self
  385. showWindow()
  386. if vm.state == .paused {
  387. vm.requestVmResume()
  388. } else {
  389. vm.requestVmStart(options: options)
  390. }
  391. #if !os(visionOS) && !WITH_LOCATION_BACKGROUND
  392. if shouldRunInBackground {
  393. UNUserNotificationCenter.current().requestAuthorization(options: .alert) { granted, _ in
  394. if !granted {
  395. logger.error("Failed to authorize notifications.")
  396. }
  397. }
  398. }
  399. #endif
  400. }
  401. func showWindow() {
  402. NotificationCenter.default.post(name: .vmSessionCreated, object: nil, userInfo: ["Session": self])
  403. }
  404. @objc private func suspend() {
  405. // dummy function for selector
  406. }
  407. func stop() {
  408. let audioSession = AVAudioSession.sharedInstance()
  409. do {
  410. try audioSession.setActive(false)
  411. } catch {
  412. logger.warning("Error stopping audio session: \(error.localizedDescription)")
  413. }
  414. // tell other screens to shut down
  415. Self.allActiveSessions.removeValue(forKey: id)
  416. closeWindows()
  417. #if WITH_SOLO_VM
  418. // animate to home screen
  419. let app = UIApplication.shared
  420. app.performSelector(onMainThread: #selector(suspend), with: nil, waitUntilDone: true)
  421. // wait 2 seconds while app is going background
  422. Thread.sleep(forTimeInterval: 2)
  423. // exit app when app is in background
  424. exit(0)
  425. #endif
  426. }
  427. func closeWindows() {
  428. NotificationCenter.default.post(name: .vmSessionEnded, object: nil, userInfo: ["Session": self])
  429. }
  430. func powerDown(isKill: Bool = false) {
  431. Task {
  432. try? await vm.deleteSnapshot(name: nil)
  433. try await vm.stop(usingMethod: isKill ? .kill : .force)
  434. self.stop()
  435. }
  436. }
  437. func pauseResume() {
  438. let shouldSaveState = !vm.isRunningAsDisposible
  439. if vm.state == .started {
  440. vm.requestVmPause(save: shouldSaveState)
  441. } else if vm.state == .paused {
  442. vm.requestVmResume()
  443. }
  444. }
  445. func reset() {
  446. vm.requestVmReset()
  447. }
  448. #if !WITH_REMOTE
  449. func sendKeys(keys: [QEMUKeyCode]) {
  450. Task {
  451. guard let monitor = await (vm as? UTMQemuVirtualMachine)?.monitor else {
  452. return
  453. }
  454. do {
  455. try await monitor.sendKeys(keys)
  456. } catch {
  457. self.nonfatalError = error.localizedDescription
  458. }
  459. }
  460. }
  461. #endif
  462. func didReceiveMemoryWarning() {
  463. let shouldAutosave = UserDefaults.standard.bool(forKey: "AutosaveLowMemory")
  464. if shouldAutosave {
  465. logger.info("Saving VM state on low memory warning.")
  466. Task {
  467. // ignore error
  468. try? await vm.saveSnapshot(name: nil)
  469. }
  470. }
  471. }
  472. func didEnterBackground() {
  473. #if !os(visionOS)
  474. logger.info("Entering background")
  475. if shouldAutosaveBackground && vmState == .started {
  476. logger.info("Saving snapshot")
  477. var task: UIBackgroundTaskIdentifier = .invalid
  478. task = UIApplication.shared.beginBackgroundTask {
  479. logger.info("Save snapshot task end")
  480. UIApplication.shared.endBackgroundTask(task)
  481. task = .invalid
  482. }
  483. Task {
  484. do {
  485. try await vm.saveSnapshot(name: nil)
  486. self.hasAutosave = true
  487. logger.info("Save snapshot complete")
  488. } catch {
  489. logger.error("error saving snapshot: \(error)")
  490. }
  491. UIApplication.shared.endBackgroundTask(task)
  492. task = .invalid
  493. }
  494. }
  495. #if !WITH_LOCATION_BACKGROUND
  496. if shouldRunInBackground && vmState == .started {
  497. backgroundTask = UIApplication.shared.beginBackgroundTask {
  498. logger.info("Background task ending")
  499. self.showBackgroundExpireNotification()
  500. }
  501. }
  502. #endif
  503. #endif
  504. }
  505. func didEnterForeground() {
  506. #if !os(visionOS)
  507. logger.info("Entering foreground!")
  508. if (hasAutosave && vmState == .started) {
  509. logger.info("Deleting snapshot")
  510. vm.requestVmDeleteState()
  511. }
  512. #if !WITH_LOCATION_BACKGROUND
  513. if let task = backgroundTask {
  514. UIApplication.shared.endBackgroundTask(task)
  515. backgroundTask = nil
  516. hideBackgroundExpireNotification()
  517. }
  518. #endif
  519. #endif
  520. }
  521. private func showBackgroundExpireNotification() {
  522. let content = UNMutableNotificationContent()
  523. content.title = NSLocalizedString("Background task is about to expire", comment: "VMSessionState")
  524. content.body = NSLocalizedString("Switch back to UTM to avoid termination.", comment: "VMSessionState")
  525. if #available(iOS 15, *) {
  526. content.interruptionLevel = .timeSensitive
  527. }
  528. let request = UNNotificationRequest(identifier: "BACKGROUND", content: content, trigger: nil)
  529. UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
  530. }
  531. private func hideBackgroundExpireNotification() {
  532. UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ["BACKGROUND"])
  533. }
  534. }
  535. extension Notification.Name {
  536. static let vmSessionCreated = Self("VMSessionCreated")
  537. static let vmSessionEnded = Self("VMSessionEnded")
  538. }