VMDisplayWindowController.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. //
  2. // Copyright © 2020 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 IOKit.pwr_mgt
  17. class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
  18. @IBOutlet weak var displayView: NSView!
  19. @IBOutlet weak var screenshotView: NSImageView!
  20. @IBOutlet weak var overlayView: NSVisualEffectView!
  21. @IBOutlet weak var activityIndicator: NSProgressIndicator!
  22. @IBOutlet weak var startButton: NSButton!
  23. @IBOutlet weak var toolbar: NSToolbar!
  24. @IBOutlet weak var stopToolbarItem: NSMenuToolbarItem!
  25. @IBOutlet weak var startPauseToolbarItem: NSToolbarItem!
  26. @IBOutlet weak var restartToolbarItem: NSToolbarItem!
  27. @IBOutlet weak var captureMouseToolbarItem: NSToolbarItem!
  28. @IBOutlet weak var captureMouseToolbarButton: NSButton!
  29. @IBOutlet weak var usbToolbarItem: NSToolbarItem!
  30. @IBOutlet weak var drivesToolbarItem: NSToolbarItem!
  31. @IBOutlet weak var sharedFolderToolbarItem: NSToolbarItem!
  32. @IBOutlet weak var resizeConsoleToolbarItem: NSToolbarItem!
  33. @IBOutlet weak var windowsToolbarItem: NSToolbarItem!
  34. @IBOutlet weak var keyboardShortcutsItem: NSToolbarItem!
  35. var shouldAutoStartVM: Bool = true
  36. var vm: (any UTMVirtualMachine)!
  37. var onClose: (() -> Void)?
  38. private(set) var secondaryWindows: [VMDisplayWindowController] = []
  39. private(set) weak var primaryWindow: VMDisplayWindowController?
  40. private var preventIdleSleepAssertion: IOPMAssertionID?
  41. private var hasSaveSnapshotFailed: Bool = false
  42. private var isFinalizing: Bool = false
  43. @Setting("PreventIdleSleep") private var isPreventIdleSleep: Bool = false
  44. @Setting("NoQuitConfirmation") private var isNoQuitConfirmation: Bool = false
  45. var isSecondary: Bool {
  46. primaryWindow != nil
  47. }
  48. override var windowNibName: NSNib.Name? {
  49. "VMDisplayWindow"
  50. }
  51. override weak var owner: AnyObject? {
  52. self
  53. }
  54. convenience init(vm: any UTMVirtualMachine, onClose: (() -> Void)?) {
  55. self.init(window: nil)
  56. self.vm = vm
  57. self.onClose = onClose
  58. NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(didWake), name: NSWorkspace.didWakeNotification, object: nil)
  59. }
  60. deinit {
  61. NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
  62. }
  63. private func stop(isKill: Bool = false) {
  64. showConfirmAlert(NSLocalizedString("This may corrupt the VM and any unsaved changes will be lost. To quit safely, shut down from the guest.", comment: "VMDisplayWindowController")) {
  65. self.enterSuspended(isBusy: true) // early indicator
  66. if self.vm.registryEntry.isSuspended {
  67. self.vm.requestVmDeleteState()
  68. }
  69. self.vm.requestVmStop(force: isKill)
  70. }
  71. }
  72. @IBAction func stopButtonPressed(_ sender: Any) {
  73. stop(isKill: false)
  74. }
  75. @IBAction func startPauseButtonPressed(_ sender: Any) {
  76. enterSuspended(isBusy: true) // early indicator
  77. if vm.state == .started {
  78. vm.requestVmPause()
  79. } else if vm.state == .paused {
  80. vm.requestVmResume()
  81. } else if vm.state == .stopped {
  82. vm.requestVmStart()
  83. } else {
  84. logger.error("Invalid state \(vm.state)")
  85. }
  86. }
  87. @IBAction func restartButtonPressed(_ sender: Any) {
  88. showConfirmAlert(NSLocalizedString("This will reset the VM and any unsaved state will be lost.", comment: "VMDisplayWindowController")) {
  89. self.vm.requestVmReset()
  90. }
  91. }
  92. @IBAction dynamic func captureMouseButtonPressed(_ sender: Any) {
  93. }
  94. @IBAction dynamic func resizeConsoleButtonPressed(_ sender: Any) {
  95. }
  96. @IBAction dynamic func usbButtonPressed(_ sender: Any) {
  97. }
  98. @IBAction dynamic func drivesButtonPressed(_ sender: Any) {
  99. }
  100. @IBAction dynamic func sharedFolderButtonPressed(_ sender: Any) {
  101. }
  102. @IBAction dynamic func windowsButtonPressed(_ sender: Any) {
  103. }
  104. @IBAction dynamic func keyboardShortcutsButtonPressed(_ sender: Any) {
  105. }
  106. // MARK: - UI states
  107. override func windowDidLoad() {
  108. window!.recalculateKeyViewLoop()
  109. setupStopButtonMenu()
  110. if vm.state == .stopped {
  111. enterSuspended(isBusy: false)
  112. } else {
  113. enterLive()
  114. }
  115. super.windowDidLoad()
  116. }
  117. public func requestAutoStart(options: UTMVirtualMachineStartOptions = []) {
  118. guard shouldAutoStartVM else {
  119. return
  120. }
  121. DispatchQueue.global(qos: .userInitiated).async {
  122. if (self.vm.state == .stopped) {
  123. self.vm.requestVmStart(options: options)
  124. } else if (self.vm.state == .paused) {
  125. self.vm.requestVmResume()
  126. }
  127. }
  128. }
  129. func enterLive() {
  130. overlayView.isHidden = true
  131. activityIndicator.stopAnimation(self)
  132. let pauseDescription = NSLocalizedString("Pause", comment: "VMDisplayWindowController")
  133. startPauseToolbarItem.image = NSImage(systemSymbolName: "pause", accessibilityDescription: pauseDescription)
  134. startPauseToolbarItem.label = pauseDescription
  135. startPauseToolbarItem.isEnabled = true
  136. stopToolbarItem.isEnabled = true
  137. restartToolbarItem.isEnabled = true
  138. captureMouseToolbarItem.isEnabled = true
  139. resizeConsoleToolbarItem.isEnabled = true
  140. windowsToolbarItem.isEnabled = true
  141. keyboardShortcutsItem.isEnabled = true
  142. window!.makeFirstResponder(displayView.subviews.first)
  143. if isPreventIdleSleep && !isSecondary {
  144. var preventIdleSleepAssertion: IOPMAssertionID = .zero
  145. let success = IOPMAssertionCreateWithName(kIOPMAssertPreventUserIdleSystemSleep as CFString,
  146. IOPMAssertionLevel(kIOPMAssertionLevelOn),
  147. "UTM Virtual Machine Running" as CFString,
  148. &preventIdleSleepAssertion)
  149. if success == kIOReturnSuccess {
  150. self.preventIdleSleepAssertion = preventIdleSleepAssertion
  151. }
  152. }
  153. }
  154. func enterSuspended(isBusy busy: Bool) {
  155. overlayView.isHidden = false
  156. let playDescription = NSLocalizedString("Play", comment: "VMDisplayWindowController")
  157. let stopped = vm.state == .stopped
  158. startPauseToolbarItem.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: playDescription)
  159. startPauseToolbarItem.label = playDescription
  160. if busy {
  161. activityIndicator.startAnimation(self)
  162. startPauseToolbarItem.isEnabled = false
  163. stopToolbarItem.isEnabled = false
  164. restartToolbarItem.isEnabled = false
  165. startButton.isHidden = true
  166. } else {
  167. activityIndicator.stopAnimation(self)
  168. startPauseToolbarItem.isEnabled = true
  169. startButton.isHidden = false
  170. stopToolbarItem.isEnabled = !stopped
  171. restartToolbarItem.isEnabled = !stopped
  172. }
  173. captureMouseToolbarItem.isEnabled = false
  174. resizeConsoleToolbarItem.isEnabled = false
  175. drivesToolbarItem.isEnabled = false
  176. sharedFolderToolbarItem.isEnabled = false
  177. usbToolbarItem.isEnabled = false
  178. windowsToolbarItem.isEnabled = false
  179. keyboardShortcutsItem.isEnabled = false
  180. window!.makeFirstResponder(nil)
  181. if let preventIdleSleepAssertion = preventIdleSleepAssertion {
  182. IOPMAssertionRelease(preventIdleSleepAssertion)
  183. }
  184. }
  185. // MARK: - Alert
  186. @MainActor
  187. func showErrorAlert(_ message: String, completionHandler handler: ((NSApplication.ModalResponse) -> Void)? = nil) {
  188. window?.resignKey()
  189. let alert = NSAlert()
  190. alert.alertStyle = .warning
  191. alert.messageText = NSLocalizedString("Error", comment: "VMDisplayWindowController")
  192. alert.informativeText = message
  193. alert.beginSheetModal(for: window!, completionHandler: handler)
  194. }
  195. @MainActor
  196. func showConfirmAlert(_ message: String, confirmHandler handler: (() -> Void)? = nil) {
  197. window?.resignKey()
  198. let alert = NSAlert()
  199. alert.alertStyle = .informational
  200. alert.messageText = NSLocalizedString("Confirmation", comment: "VMDisplayWindowController")
  201. alert.informativeText = message
  202. alert.addButton(withTitle: NSLocalizedString("OK", comment: "VMDisplayWindowController"))
  203. alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "VMDisplayWindowController"))
  204. alert.beginSheetModal(for: window!) { response in
  205. if response == .alertFirstButtonReturn {
  206. handler?()
  207. }
  208. }
  209. }
  210. @nonobjc nonisolated func withErrorAlert(_ callback: @escaping () async throws -> Void) {
  211. Task.detached(priority: .background) { [self] in
  212. do {
  213. try await callback()
  214. } catch {
  215. Task { @MainActor in
  216. showErrorAlert(error.localizedDescription)
  217. }
  218. }
  219. }
  220. }
  221. // MARK: - Create a secondary window
  222. func registerSecondaryWindow(_ secondaryWindow: VMDisplayWindowController, at index: Int? = nil) {
  223. secondaryWindows.insert(secondaryWindow, at: index ?? secondaryWindows.endIndex)
  224. secondaryWindow.onClose = { [weak self] in
  225. self?.secondaryWindows.removeAll(where: { $0 == secondaryWindow })
  226. }
  227. secondaryWindow.primaryWindow = self
  228. secondaryWindow.showWindow(self)
  229. self.showWindow(self) // show primary window on top
  230. secondaryWindow.virtualMachine(vm, didTransitionToState: vm.state) // show correct starting state
  231. }
  232. // MARK: - Virtual machine delegate
  233. func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
  234. Task { @MainActor in
  235. guard !isFinalizing else {
  236. return
  237. }
  238. switch state {
  239. case .stopped, .paused:
  240. enterSuspended(isBusy: false)
  241. case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
  242. enterSuspended(isBusy: true)
  243. case .started:
  244. enterLive()
  245. }
  246. for subwindow in secondaryWindows {
  247. subwindow.virtualMachine(vm, didTransitionToState: state)
  248. }
  249. }
  250. }
  251. func virtualMachine(_ vm: any UTMVirtualMachine, didErrorWithMessage message: String) {
  252. Task { @MainActor in
  253. guard !isFinalizing else {
  254. return
  255. }
  256. showErrorAlert(message) { _ in
  257. if vm.state != .started && vm.state != .paused {
  258. self.close()
  259. }
  260. }
  261. }
  262. }
  263. func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
  264. }
  265. func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
  266. }
  267. }
  268. extension VMDisplayWindowController: NSWindowDelegate {
  269. func window(_ window: NSWindow, willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions = []) -> NSApplication.PresentationOptions {
  270. return proposedOptions.union([.autoHideToolbar])
  271. }
  272. func windowShouldClose(_ sender: NSWindow) -> Bool {
  273. guard !isSecondary else {
  274. return true
  275. }
  276. guard !(vm.state == .stopped || (vm.state == .paused && vm.registryEntry.isSuspended)) else {
  277. return true
  278. }
  279. if let snapshotUnsupportedError = vm.snapshotUnsupportedError {
  280. return windowWillCloseAfterConfirmation(sender, error: snapshotUnsupportedError)
  281. } else if hasSaveSnapshotFailed {
  282. return windowWillCloseAfterConfirmation(sender)
  283. } else {
  284. return windowWillCloseAfterSaving(sender)
  285. }
  286. }
  287. private func windowWillCloseAfterConfirmation(_ sender: NSWindow, error: Error? = nil) -> Bool {
  288. guard !isNoQuitConfirmation else {
  289. return true
  290. }
  291. let alert = NSAlert()
  292. alert.alertStyle = .informational
  293. if error == nil {
  294. alert.messageText = NSLocalizedString("Confirmation", comment: "VMDisplayWindowController")
  295. } else {
  296. alert.messageText = NSLocalizedString("Failed to save suspend state", comment: "VMDisplayWindowController")
  297. }
  298. alert.informativeText = NSLocalizedString("Closing this window will kill the VM.", comment: "VMQemuDisplayMetalWindowController")
  299. if let error = error {
  300. alert.informativeText = error.localizedDescription + "\n" + alert.informativeText
  301. }
  302. alert.addButton(withTitle: NSLocalizedString("OK", comment: "VMDisplayWindowController"))
  303. alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "VMDisplayWindowController"))
  304. alert.showsSuppressionButton = true
  305. alert.beginSheetModal(for: sender) { response in
  306. switch response {
  307. case .alertFirstButtonReturn:
  308. if alert.suppressionButton?.state == .on {
  309. self.isNoQuitConfirmation = true
  310. }
  311. sender.close()
  312. default:
  313. return
  314. }
  315. }
  316. return false
  317. }
  318. private func windowWillCloseAfterSaving(_ sender: NSWindow) -> Bool {
  319. Task {
  320. do {
  321. try await vm.saveSnapshot(name: nil)
  322. vm.delegate = nil
  323. self.enterSuspended(isBusy: false)
  324. sender.close()
  325. } catch {
  326. hasSaveSnapshotFailed = true
  327. _ = windowWillCloseAfterConfirmation(sender, error: error)
  328. }
  329. }
  330. return false
  331. }
  332. func windowWillClose(_ notification: Notification) {
  333. if !isSecondary {
  334. self.vm.requestVmStop(force: true)
  335. }
  336. secondaryWindows.forEach { secondaryWindow in
  337. secondaryWindow.close()
  338. }
  339. if let preventIdleSleepAssertion = preventIdleSleepAssertion {
  340. IOPMAssertionRelease(preventIdleSleepAssertion)
  341. }
  342. isFinalizing = true
  343. onClose?()
  344. }
  345. func windowDidBecomeKey(_ notification: Notification) {
  346. if let window = self.window {
  347. _ = window.makeFirstResponder(displayView.subviews.first)
  348. }
  349. }
  350. func windowDidResignKey(_ notification: Notification) {
  351. if let window = self.window {
  352. _ = window.makeFirstResponder(nil)
  353. }
  354. }
  355. }
  356. // MARK: - Toolbar
  357. extension VMDisplayWindowController: NSToolbarItemValidation {
  358. func validateToolbarItem(_ item: NSToolbarItem) -> Bool {
  359. return true
  360. }
  361. }
  362. // MARK: - Stop menu
  363. extension VMDisplayWindowController {
  364. private func setupStopButtonMenu() {
  365. let menu = NSMenu()
  366. menu.autoenablesItems = false
  367. let item1 = NSMenuItem()
  368. item1.title = NSLocalizedString("Request power down", comment: "VMDisplayWindowController")
  369. item1.toolTip = NSLocalizedString("Sends power down request to the guest. This simulates pressing the power button on a PC.", comment: "VMDisplayWindowController")
  370. item1.target = self
  371. item1.action = #selector(requestPowerDown)
  372. menu.addItem(item1)
  373. let item2 = NSMenuItem()
  374. item2.title = NSLocalizedString("Force shut down", comment: "VMDisplayWindowController")
  375. item2.toolTip = NSLocalizedString("Tells the VM process to shut down with risk of data corruption. This simulates holding down the power button on a PC.", comment: "VMDisplayWindowController")
  376. item2.target = self
  377. item2.action = #selector(forceShutDown)
  378. menu.addItem(item2)
  379. if type(of: vm).capabilities.supportsProcessKill {
  380. let item3 = NSMenuItem()
  381. item3.title = NSLocalizedString("Force kill", comment: "VMDisplayWindowController")
  382. item3.toolTip = NSLocalizedString("Force kill the VM process with high risk of data corruption.", comment: "VMDisplayWindowController")
  383. item3.target = self
  384. item3.action = #selector(forceKill)
  385. menu.addItem(item3)
  386. }
  387. stopToolbarItem.menu = menu
  388. if #unavailable(macOS 12), let view = stopToolbarItem.value(forKey: "_control") as? NSView {
  389. // BUG in macOS 11 results in the button not working without this
  390. stopToolbarItem.view = view
  391. }
  392. }
  393. @MainActor @objc private func requestPowerDown(sender: AnyObject) {
  394. vm.requestGuestPowerDown()
  395. }
  396. @MainActor @objc private func forceShutDown(sender: AnyObject) {
  397. stop()
  398. }
  399. @MainActor @objc private func forceKill(sender: AnyObject) {
  400. stop(isKill: true)
  401. }
  402. }
  403. // MARK: - Computer wakeup
  404. extension VMDisplayWindowController {
  405. @objc func didWake(_ notification: NSNotification) {
  406. // do something in subclass
  407. }
  408. }