VMDisplayWindowController.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  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 {
  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. var isPowerForce: Bool = false
  35. var shouldAutoStartVM: Bool = true
  36. var shouldSaveOnPause: Bool { true }
  37. var vm: UTMVirtualMachine!
  38. var onClose: ((Notification) -> Void)?
  39. private(set) var secondaryWindows: [VMDisplayWindowController] = []
  40. private(set) weak var primaryWindow: VMDisplayWindowController?
  41. private var preventIdleSleepAssertion: IOPMAssertionID?
  42. @Setting("PreventIdleSleep") private var isPreventIdleSleep: Bool = false
  43. @Setting("NoQuitConfirmation") private var isNoQuitConfirmation: Bool = false
  44. var isSecondary: Bool {
  45. primaryWindow != nil
  46. }
  47. override var windowNibName: NSNib.Name? {
  48. "VMDisplayWindow"
  49. }
  50. override weak var owner: AnyObject? {
  51. self
  52. }
  53. convenience init(vm: UTMVirtualMachine, onClose: ((Notification) -> Void)?) {
  54. self.init(window: nil)
  55. self.vm = vm
  56. self.onClose = onClose
  57. NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(didWake), name: NSWorkspace.didWakeNotification, object: nil)
  58. }
  59. deinit {
  60. NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.didWakeNotification, object: nil)
  61. }
  62. @IBAction func stopButtonPressed(_ sender: Any) {
  63. showConfirmAlert(NSLocalizedString("This may corrupt the VM and any unsaved changes will be lost. To quit safely, shut down from the guest.", comment: "VMDisplayWindowController")) {
  64. self.enterSuspended(isBusy: true) // early indicator
  65. self.vm.requestVmDeleteState()
  66. self.vm.requestVmStop(force: self.isPowerForce)
  67. }
  68. }
  69. @IBAction func startPauseButtonPressed(_ sender: Any) {
  70. enterSuspended(isBusy: true) // early indicator
  71. if vm.state == .vmStarted {
  72. vm.requestVmPause(save: shouldSaveOnPause)
  73. } else if vm.state == .vmPaused {
  74. vm.requestVmResume()
  75. } else if vm.state == .vmStopped {
  76. vm.requestVmStart()
  77. } else {
  78. logger.error("Invalid state \(vm.state)")
  79. }
  80. }
  81. @IBAction func restartButtonPressed(_ sender: Any) {
  82. showConfirmAlert(NSLocalizedString("This will reset the VM and any unsaved state will be lost.", comment: "VMDisplayWindowController")) {
  83. self.vm.requestVmReset()
  84. }
  85. }
  86. @IBAction dynamic func captureMouseButtonPressed(_ sender: Any) {
  87. }
  88. @IBAction dynamic func resizeConsoleButtonPressed(_ sender: Any) {
  89. }
  90. @IBAction dynamic func usbButtonPressed(_ sender: Any) {
  91. }
  92. @IBAction dynamic func drivesButtonPressed(_ sender: Any) {
  93. }
  94. @IBAction dynamic func sharedFolderButtonPressed(_ sender: Any) {
  95. }
  96. @IBAction dynamic func windowsButtonPressed(_ sender: Any) {
  97. }
  98. // MARK: - UI states
  99. override func windowDidLoad() {
  100. window!.recalculateKeyViewLoop()
  101. setupStopButtonMenu()
  102. if vm.state == .vmStopped {
  103. enterSuspended(isBusy: false)
  104. } else {
  105. enterLive()
  106. }
  107. super.windowDidLoad()
  108. }
  109. public func requestAutoStart() {
  110. guard shouldAutoStartVM else {
  111. return
  112. }
  113. DispatchQueue.global(qos: .userInitiated).async {
  114. if (self.vm.state == .vmStopped) {
  115. self.vm.requestVmStart()
  116. } else if (self.vm.state == .vmPaused) {
  117. self.vm.requestVmResume()
  118. }
  119. }
  120. }
  121. func enterLive() {
  122. overlayView.isHidden = true
  123. activityIndicator.stopAnimation(self)
  124. let pauseDescription = NSLocalizedString("Pause", comment: "VMDisplayWindowController")
  125. startPauseToolbarItem.image = NSImage(systemSymbolName: "pause", accessibilityDescription: pauseDescription)
  126. startPauseToolbarItem.label = pauseDescription
  127. stopToolbarItem.isEnabled = true
  128. restartToolbarItem.isEnabled = true
  129. captureMouseToolbarItem.isEnabled = true
  130. resizeConsoleToolbarItem.isEnabled = true
  131. windowsToolbarItem.isEnabled = true
  132. window!.makeFirstResponder(displayView.subviews.first)
  133. if isPreventIdleSleep && !isSecondary {
  134. var preventIdleSleepAssertion: IOPMAssertionID = .zero
  135. let success = IOPMAssertionCreateWithName(kIOPMAssertPreventUserIdleSystemSleep as CFString,
  136. IOPMAssertionLevel(kIOPMAssertionLevelOn),
  137. "UTM Virtual Machine Running" as CFString,
  138. &preventIdleSleepAssertion)
  139. if success == kIOReturnSuccess {
  140. self.preventIdleSleepAssertion = preventIdleSleepAssertion
  141. }
  142. }
  143. }
  144. func enterSuspended(isBusy busy: Bool) {
  145. overlayView.isHidden = false
  146. let playDescription = NSLocalizedString("Play", comment: "VMDisplayWindowController")
  147. let stopped = vm.state == .vmStopped
  148. startPauseToolbarItem.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: playDescription)
  149. startPauseToolbarItem.label = playDescription
  150. if busy {
  151. activityIndicator.startAnimation(self)
  152. startPauseToolbarItem.isEnabled = false
  153. stopToolbarItem.isEnabled = false
  154. restartToolbarItem.isEnabled = false
  155. startButton.isHidden = true
  156. } else {
  157. activityIndicator.stopAnimation(self)
  158. startPauseToolbarItem.isEnabled = true
  159. startButton.isHidden = false
  160. stopToolbarItem.isEnabled = !stopped
  161. restartToolbarItem.isEnabled = !stopped
  162. }
  163. captureMouseToolbarItem.isEnabled = false
  164. resizeConsoleToolbarItem.isEnabled = false
  165. drivesToolbarItem.isEnabled = false
  166. sharedFolderToolbarItem.isEnabled = false
  167. usbToolbarItem.isEnabled = false
  168. windowsToolbarItem.isEnabled = false
  169. window!.makeFirstResponder(nil)
  170. if let preventIdleSleepAssertion = preventIdleSleepAssertion {
  171. IOPMAssertionRelease(preventIdleSleepAssertion)
  172. }
  173. }
  174. // MARK: - Alert
  175. func showErrorAlert(_ message: String, completionHandler handler: ((NSApplication.ModalResponse) -> Void)? = nil) {
  176. let alert = NSAlert()
  177. alert.alertStyle = .critical
  178. alert.messageText = NSLocalizedString("Error", comment: "VMDisplayWindowController")
  179. alert.informativeText = message
  180. alert.beginSheetModal(for: window!, completionHandler: handler)
  181. }
  182. func showConfirmAlert(_ message: String, confirmHandler handler: (() -> Void)? = nil) {
  183. let alert = NSAlert()
  184. alert.alertStyle = .informational
  185. alert.messageText = NSLocalizedString("Confirmation", comment: "VMDisplayWindowController")
  186. alert.informativeText = message
  187. alert.addButton(withTitle: NSLocalizedString("OK", comment: "VMDisplayWindowController"))
  188. alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "VMDisplayWindowController"))
  189. alert.beginSheetModal(for: window!) { response in
  190. if response == .alertFirstButtonReturn {
  191. handler?()
  192. }
  193. }
  194. }
  195. // MARK: - Create a secondary window
  196. func registerSecondaryWindow(_ secondaryWindow: VMDisplayWindowController, at index: Int? = nil) {
  197. secondaryWindows.insert(secondaryWindow, at: index ?? secondaryWindows.endIndex)
  198. secondaryWindow.onClose = { [weak self] _ in
  199. self?.secondaryWindows.removeAll(where: { $0 == secondaryWindow })
  200. }
  201. secondaryWindow.primaryWindow = self
  202. secondaryWindow.showWindow(self)
  203. self.showWindow(self) // show primary window on top
  204. secondaryWindow.virtualMachine(vm, didTransitionTo: vm.state) // show correct starting state
  205. }
  206. }
  207. extension VMDisplayWindowController: NSWindowDelegate {
  208. func window(_ window: NSWindow, willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions = []) -> NSApplication.PresentationOptions {
  209. return proposedOptions.union([.autoHideToolbar])
  210. }
  211. func windowShouldClose(_ sender: NSWindow) -> Bool {
  212. guard !isSecondary else {
  213. return true
  214. }
  215. guard !(vm.state == .vmStopped || (vm.state == .vmPaused && vm.hasSaveState)) else {
  216. return true
  217. }
  218. guard !isNoQuitConfirmation else {
  219. return true
  220. }
  221. let alert = NSAlert()
  222. alert.alertStyle = .informational
  223. alert.messageText = NSLocalizedString("Confirmation", comment: "VMDisplayWindowController")
  224. alert.informativeText = NSLocalizedString("Closing this window will kill the VM.", comment: "VMQemuDisplayMetalWindowController")
  225. alert.addButton(withTitle: NSLocalizedString("OK", comment: "VMDisplayWindowController"))
  226. alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "VMDisplayWindowController"))
  227. alert.showsSuppressionButton = true
  228. alert.beginSheetModal(for: sender) { response in
  229. switch response {
  230. case .alertFirstButtonReturn:
  231. if alert.suppressionButton?.state == .on {
  232. self.isNoQuitConfirmation = true
  233. }
  234. sender.close()
  235. default:
  236. return
  237. }
  238. }
  239. return false
  240. }
  241. func windowWillClose(_ notification: Notification) {
  242. if !isSecondary {
  243. self.vm.requestVmStop(force: true)
  244. }
  245. secondaryWindows.forEach { secondaryWindow in
  246. secondaryWindow.close()
  247. }
  248. if let preventIdleSleepAssertion = preventIdleSleepAssertion {
  249. IOPMAssertionRelease(preventIdleSleepAssertion)
  250. }
  251. onClose?(notification)
  252. }
  253. func windowDidBecomeKey(_ notification: Notification) {
  254. if let window = self.window {
  255. _ = window.makeFirstResponder(displayView.subviews.first)
  256. }
  257. }
  258. func windowDidResignKey(_ notification: Notification) {
  259. if let window = self.window {
  260. _ = window.makeFirstResponder(nil)
  261. }
  262. }
  263. }
  264. // MARK: - Toolbar
  265. extension VMDisplayWindowController: NSToolbarItemValidation {
  266. func validateToolbarItem(_ item: NSToolbarItem) -> Bool {
  267. return true
  268. }
  269. }
  270. // MARK: - Stop menu
  271. extension VMDisplayWindowController {
  272. private func setupStopButtonMenu() {
  273. let menu = NSMenu()
  274. menu.autoenablesItems = false
  275. let item1 = NSMenuItem()
  276. item1.title = NSLocalizedString("Request power down", comment: "VMDisplayWindowController")
  277. item1.toolTip = NSLocalizedString("Sends power down request to the guest. This simulates pressing the power button on a PC.", comment: "VMDisplayWindowController")
  278. item1.target = self
  279. item1.action = #selector(requestPowerDown)
  280. menu.addItem(item1)
  281. let item2 = NSMenuItem()
  282. item2.title = NSLocalizedString("Force shut down", comment: "VMDisplayWindowController")
  283. 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")
  284. item2.target = self
  285. item2.action = #selector(forceShutDown)
  286. menu.addItem(item2)
  287. let item3 = NSMenuItem()
  288. item3.title = NSLocalizedString("Force kill", comment: "VMDisplayWindowController")
  289. item3.toolTip = NSLocalizedString("Force kill the VM process with high risk of data corruption.", comment: "VMDisplayWindowController")
  290. item3.target = self
  291. item3.action = #selector(forceKill)
  292. menu.addItem(item3)
  293. stopToolbarItem.menu = menu
  294. }
  295. @MainActor @objc private func requestPowerDown(sender: AnyObject) {
  296. vm.requestGuestPowerDown()
  297. }
  298. @MainActor @objc private func forceShutDown(sender: AnyObject) {
  299. let prev = isPowerForce
  300. isPowerForce = false
  301. stopButtonPressed(sender)
  302. isPowerForce = prev
  303. }
  304. @MainActor @objc private func forceKill(sender: AnyObject) {
  305. let prev = isPowerForce
  306. isPowerForce = true
  307. stopButtonPressed(sender)
  308. isPowerForce = prev
  309. }
  310. }
  311. // MARK: - VM Delegate
  312. extension VMDisplayWindowController: UTMVirtualMachineDelegate {
  313. func virtualMachine(_ vm: UTMVirtualMachine, didTransitionTo state: UTMVMState) {
  314. switch state {
  315. case .vmStopped, .vmPaused:
  316. enterSuspended(isBusy: false)
  317. case .vmPausing, .vmStopping, .vmStarting, .vmResuming:
  318. enterSuspended(isBusy: true)
  319. case .vmStarted:
  320. enterLive()
  321. @unknown default:
  322. break
  323. }
  324. for subwindow in secondaryWindows {
  325. subwindow.virtualMachine(vm, didTransitionTo: state)
  326. }
  327. }
  328. func virtualMachine(_ vm: UTMVirtualMachine, didErrorWithMessage message: String) {
  329. showErrorAlert(message) { _ in
  330. if vm.state != .vmStarted && vm.state != .vmPaused {
  331. self.close()
  332. }
  333. }
  334. }
  335. }
  336. // MARK: - Computer wakeup
  337. extension VMDisplayWindowController {
  338. @objc private func didWake(_ notification: NSNotification) {
  339. if let qemuVM = vm as? UTMQemuVirtualMachine {
  340. Task {
  341. try? await qemuVM.guestAgent?.guestSetTime(NSDate.now.timeIntervalSince1970)
  342. }
  343. }
  344. }
  345. }