VMDisplayAppleWindowController.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. //
  2. // Copyright © 2021 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. class VMDisplayAppleWindowController: VMDisplayWindowController {
  18. var mainView: NSView?
  19. var isInstallSuccessful: Bool = false
  20. var appleVM: UTMAppleVirtualMachine! {
  21. vm as? UTMAppleVirtualMachine
  22. }
  23. var appleConfig: UTMAppleConfiguration! {
  24. appleVM?.config
  25. }
  26. var defaultTitle: String {
  27. appleConfig.information.name
  28. }
  29. var defaultSubtitle: String {
  30. ""
  31. }
  32. private var isSharePathAlertShownOnce = false
  33. // MARK: - User preferences
  34. @Setting("SharePathAlertShown") private var isSharePathAlertShownPersistent: Bool = false
  35. override func windowDidLoad() {
  36. mainView!.translatesAutoresizingMaskIntoConstraints = false
  37. displayView.addSubview(mainView!)
  38. NSLayoutConstraint.activate(mainView!.constraintsForAnchoringTo(boundsOf: displayView))
  39. appleVM.screenshotDelegate = self
  40. window!.recalculateKeyViewLoop()
  41. if #available(macOS 12, *) {
  42. shouldAutoStartVM = appleConfig.system.boot.macRecoveryIpswURL == nil
  43. }
  44. super.windowDidLoad()
  45. if #available(macOS 12, *), let ipswUrl = appleConfig.system.boot.macRecoveryIpswURL {
  46. showConfirmAlert(NSLocalizedString("Would you like to install macOS? If an existing operating system is already installed on the primary drive of this VM, then it will be erased.", comment: "VMDisplayAppleWindowController")) {
  47. self.isInstallSuccessful = false
  48. self.appleVM.requestInstallVM(with: ipswUrl)
  49. }
  50. }
  51. if !isSecondary {
  52. // create remaining serial windows
  53. let primarySerialIndex = appleConfig.serials.firstIndex { $0.mode == .builtin }
  54. for i in appleConfig.serials.indices {
  55. if i == primarySerialIndex && self is VMDisplayAppleTerminalWindowController {
  56. continue
  57. }
  58. if appleConfig.serials[i].mode != .builtin || appleConfig.serials[i].terminal == nil {
  59. continue
  60. }
  61. let vc = VMDisplayAppleTerminalWindowController(secondaryForIndex: i, vm: appleVM)
  62. registerSecondaryWindow(vc)
  63. }
  64. }
  65. }
  66. override func enterLive() {
  67. window!.title = defaultTitle
  68. window!.subtitle = defaultSubtitle
  69. updateWindowFrame()
  70. super.enterLive()
  71. drivesToolbarItem.isEnabled = false
  72. usbToolbarItem.isEnabled = false
  73. resizeConsoleToolbarItem.isEnabled = false
  74. if #available(macOS 13, *) {
  75. sharedFolderToolbarItem.isEnabled = true
  76. } else if #available(macOS 12, *) {
  77. sharedFolderToolbarItem.isEnabled = appleConfig.system.boot.operatingSystem == .linux
  78. } else {
  79. // stop() not available on macOS 11 for some reason
  80. restartToolbarItem.isEnabled = false
  81. sharedFolderToolbarItem.isEnabled = false
  82. }
  83. if #available(macOS 15, *) {
  84. drivesToolbarItem.isEnabled = true
  85. }
  86. }
  87. override func enterSuspended(isBusy busy: Bool) {
  88. super.enterSuspended(isBusy: busy)
  89. }
  90. override func virtualMachine(_ vm: any UTMVirtualMachine, didTransitionToState state: UTMVirtualMachineState) {
  91. super.virtualMachine(vm, didTransitionToState: state)
  92. if state == .stopped && isInstallSuccessful {
  93. isInstallSuccessful = false
  94. vm.requestVmStart()
  95. }
  96. }
  97. func updateWindowFrame() {
  98. // implement in subclass
  99. }
  100. override func resizeConsoleButtonPressed(_ sender: Any) {
  101. // implement in subclass
  102. }
  103. @IBAction override func sharedFolderButtonPressed(_ sender: Any) {
  104. guard #available(macOS 12, *) else {
  105. return
  106. }
  107. guard appleConfig.system.boot.operatingSystem == .linux else {
  108. openShareMenu(sender)
  109. return
  110. }
  111. if !isSharePathAlertShownOnce && !isSharePathAlertShownPersistent {
  112. let alert = NSAlert()
  113. alert.messageText = NSLocalizedString("Directory sharing", comment: "VMDisplayAppleWindowController")
  114. alert.informativeText = NSLocalizedString("To access the shared directory, the guest OS must have Virtiofs drivers installed. You can then run `sudo mount -t virtiofs share /path/to/share` to mount to the share path.", comment: "VMDisplayAppleWindowController")
  115. alert.showsSuppressionButton = true
  116. alert.beginSheetModal(for: window!) { _ in
  117. if alert.suppressionButton?.state ?? .off == .on {
  118. self.isSharePathAlertShownPersistent = true
  119. }
  120. self.isSharePathAlertShownOnce = true
  121. }
  122. } else {
  123. openShareMenu(sender)
  124. }
  125. }
  126. // MARK: - Installation progress
  127. override func virtualMachine(_ vm: any UTMVirtualMachine, didCompleteInstallation success: Bool) {
  128. Task { @MainActor in
  129. self.window!.subtitle = ""
  130. if success {
  131. // delete IPSW setting
  132. self.enterSuspended(isBusy: true)
  133. self.appleConfig.system.boot.macRecoveryIpswURL = nil
  134. self.appleVM.registryEntry.macRecoveryIpsw = nil
  135. self.isInstallSuccessful = true
  136. }
  137. }
  138. }
  139. override func virtualMachine(_ vm: any UTMVirtualMachine, didUpdateInstallationProgress progress: Double) {
  140. Task { @MainActor in
  141. let installationFormat = NSLocalizedString("Installation: %@", comment: "VMDisplayAppleWindowController")
  142. let percentString = NumberFormatter.localizedString(from: progress as NSNumber, number: .percent)
  143. self.window!.subtitle = String.localizedStringWithFormat(installationFormat, percentString)
  144. }
  145. }
  146. }
  147. @available(macOS 12, *)
  148. extension VMDisplayAppleWindowController {
  149. func openShareMenu(_ sender: Any) {
  150. let menu = NSMenu()
  151. let entry = appleVM.registryEntry
  152. for i in entry.sharedDirectories.indices {
  153. let item = NSMenuItem()
  154. let sharedDirectory = entry.sharedDirectories[i]
  155. let name = sharedDirectory.url.lastPathComponent
  156. item.title = name
  157. let submenu = NSMenu()
  158. let ro = NSMenuItem(title: NSLocalizedString("Read Only", comment: "VMDisplayAppleController"),
  159. action: #selector(flipReadOnlyShare),
  160. keyEquivalent: "")
  161. ro.target = self
  162. ro.tag = i
  163. ro.state = sharedDirectory.isReadOnly ? .on : .off
  164. submenu.addItem(ro)
  165. let change = NSMenuItem(title: NSLocalizedString("Change…", comment: "VMDisplayAppleController"),
  166. action: #selector(changeShare),
  167. keyEquivalent: "")
  168. change.target = self
  169. change.tag = i
  170. submenu.addItem(change)
  171. let remove = NSMenuItem(title: NSLocalizedString("Remove…", comment: "VMDisplayAppleController"),
  172. action: #selector(removeShare),
  173. keyEquivalent: "")
  174. remove.target = self
  175. remove.tag = i
  176. submenu.addItem(remove)
  177. item.submenu = submenu
  178. menu.addItem(item)
  179. }
  180. let add = NSMenuItem(title: NSLocalizedString("Add…", comment: "VMDisplayAppleController"),
  181. action: #selector(addShare),
  182. keyEquivalent: "")
  183. add.target = self
  184. menu.addItem(add)
  185. menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil)
  186. }
  187. @objc func addShare(sender: AnyObject) {
  188. pickShare { url in
  189. if let sharedDirectory = try? UTMRegistryEntry.File(url: url) {
  190. self.appleVM.registryEntry.sharedDirectories.append(sharedDirectory)
  191. }
  192. }
  193. }
  194. @objc func changeShare(sender: AnyObject) {
  195. guard let menu = sender as? NSMenuItem else {
  196. logger.error("wrong sender for changeShare")
  197. return
  198. }
  199. let i = menu.tag
  200. let isReadOnly = appleVM.registryEntry.sharedDirectories[i].isReadOnly
  201. pickShare { url in
  202. if let sharedDirectory = try? UTMRegistryEntry.File(url: url, isReadOnly: isReadOnly) {
  203. self.appleVM.registryEntry.sharedDirectories[i] = sharedDirectory
  204. }
  205. }
  206. }
  207. @objc func flipReadOnlyShare(sender: AnyObject) {
  208. guard let menu = sender as? NSMenuItem else {
  209. logger.error("wrong sender for changeShare")
  210. return
  211. }
  212. let i = menu.tag
  213. let isReadOnly = appleVM.registryEntry.sharedDirectories[i].isReadOnly
  214. appleVM.registryEntry.sharedDirectories[i].isReadOnly = !isReadOnly
  215. }
  216. @objc func removeShare(sender: AnyObject) {
  217. guard let menu = sender as? NSMenuItem else {
  218. logger.error("wrong sender for removeShare")
  219. return
  220. }
  221. let i = menu.tag
  222. appleVM.registryEntry.sharedDirectories.remove(at: i)
  223. }
  224. func pickShare(_ onComplete: @escaping (URL) -> Void) {
  225. let openPanel = NSOpenPanel()
  226. openPanel.title = NSLocalizedString("Select Shared Folder", comment: "VMDisplayAppleWindowController")
  227. openPanel.canChooseDirectories = true
  228. openPanel.canChooseFiles = false
  229. openPanel.beginSheetModal(for: window!) { response in
  230. guard response == .OK else {
  231. return
  232. }
  233. guard let url = openPanel.url else {
  234. logger.debug("no directory selected")
  235. return
  236. }
  237. onComplete(url)
  238. }
  239. }
  240. }
  241. @objc extension VMDisplayAppleWindowController {
  242. @IBAction override func drivesButtonPressed(_ sender: Any) {
  243. let menu = NSMenu()
  244. menu.autoenablesItems = false
  245. let item = NSMenuItem()
  246. item.title = NSLocalizedString("Querying drives status...", comment: "VMDisplayWindowController")
  247. item.isEnabled = false
  248. menu.addItem(item)
  249. updateDrivesMenu(menu, drives: appleConfig.drives)
  250. menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil)
  251. }
  252. @nonobjc func updateDrivesMenu(_ menu: NSMenu, drives: [UTMAppleConfigurationDrive]) {
  253. menu.removeAllItems()
  254. if drives.count == 0 {
  255. let item = NSMenuItem()
  256. item.title = NSLocalizedString("No drives connected.", comment: "VMDisplayWindowController")
  257. item.isEnabled = false
  258. menu.addItem(item)
  259. }
  260. if #available(macOS 15, *), appleConfig.system.boot.operatingSystem == .macOS {
  261. let item = NSMenuItem()
  262. item.title = NSLocalizedString("Install Guest Tools…", comment: "VMDisplayAppleWindowController")
  263. item.isEnabled = !appleConfig.isGuestToolsInstallRequested
  264. item.state = appleVM.hasGuestToolsAttached ? .on : .off
  265. item.target = self
  266. item.action = #selector(installGuestTools)
  267. menu.addItem(item)
  268. }
  269. for i in drives.indices {
  270. let drive = drives[i]
  271. if !drive.isExternal {
  272. continue // skip non-disks
  273. }
  274. let item = NSMenuItem()
  275. item.title = label(for: drive)
  276. if !drive.isExternal {
  277. item.isEnabled = false
  278. } else if #available(macOS 15, *) {
  279. let submenu = NSMenu()
  280. submenu.autoenablesItems = false
  281. let eject = NSMenuItem(title: NSLocalizedString("Eject", comment: "VMDisplayWindowController"),
  282. action: #selector(ejectDrive),
  283. keyEquivalent: "")
  284. eject.target = self
  285. eject.tag = i
  286. eject.isEnabled = drive.imageURL != nil
  287. submenu.addItem(eject)
  288. let change = NSMenuItem(title: NSLocalizedString("Change", comment: "VMDisplayWindowController"),
  289. action: #selector(changeDriveImage),
  290. keyEquivalent: "")
  291. change.target = self
  292. change.tag = i
  293. change.isEnabled = true
  294. submenu.addItem(change)
  295. item.submenu = submenu
  296. }
  297. menu.addItem(item)
  298. }
  299. menu.update()
  300. }
  301. @nonobjc private func withErrorAlert(_ callback: @escaping () async throws -> Void) {
  302. Task.detached(priority: .background) { [self] in
  303. do {
  304. try await callback()
  305. } catch {
  306. Task { @MainActor in
  307. showErrorAlert(error.localizedDescription)
  308. }
  309. }
  310. }
  311. }
  312. @available(macOS 15, *)
  313. func ejectDrive(sender: AnyObject) {
  314. guard let menu = sender as? NSMenuItem else {
  315. logger.error("wrong sender for ejectDrive")
  316. return
  317. }
  318. let drive = appleConfig.drives[menu.tag]
  319. withErrorAlert {
  320. try await self.appleVM.eject(drive)
  321. }
  322. }
  323. @available(macOS 15, *)
  324. func openDriveImage(forDriveIndex index: Int) {
  325. let drive = appleConfig.drives[index]
  326. let openPanel = NSOpenPanel()
  327. openPanel.title = NSLocalizedString("Select Drive Image", comment: "VMDisplayWindowController")
  328. openPanel.allowedContentTypes = [.data]
  329. openPanel.beginSheetModal(for: window!) { response in
  330. guard response == .OK else {
  331. return
  332. }
  333. guard let url = openPanel.url else {
  334. logger.debug("no file selected")
  335. return
  336. }
  337. self.withErrorAlert {
  338. try await self.appleVM.changeMedium(drive, to: url)
  339. }
  340. }
  341. }
  342. @available(macOS 15, *)
  343. func changeDriveImage(sender: AnyObject) {
  344. guard let menu = sender as? NSMenuItem else {
  345. logger.error("wrong sender for ejectDrive")
  346. return
  347. }
  348. openDriveImage(forDriveIndex: menu.tag)
  349. }
  350. @nonobjc private func label(for drive: UTMAppleConfigurationDrive) -> String {
  351. let imageURL = drive.imageURL
  352. return String.localizedStringWithFormat(NSLocalizedString("USB Mass Storage: %@", comment: "VMDisplayAppleDisplayController"),
  353. imageURL?.lastPathComponent ?? NSLocalizedString("none", comment: "VMDisplayAppleDisplayController"))
  354. }
  355. @available(macOS 15, *)
  356. @MainActor private func installGuestTools(sender: AnyObject) {
  357. if appleVM.hasGuestToolsAttached {
  358. withErrorAlert {
  359. try await self.appleVM.detachGuestTools()
  360. }
  361. } else {
  362. showConfirmAlert(NSLocalizedString("An USB device containing the installer will be mounted in the virtual machine. Only macOS Sequoia (15.0) and newer guests are supported.", comment: "VMDisplayAppleDisplayController")) {
  363. self.appleConfig.isGuestToolsInstallRequested = true
  364. }
  365. }
  366. }
  367. }
  368. extension VMDisplayAppleWindowController: UTMScreenshotProvider {
  369. var screenshot: UTMVirtualMachineScreenshot? {
  370. if let image = mainView?.image() {
  371. return UTMVirtualMachineScreenshot(wrapping: image)
  372. } else {
  373. return nil
  374. }
  375. }
  376. }
  377. extension VMDisplayAppleWindowController {
  378. @IBAction override func windowsButtonPressed(_ sender: Any) {
  379. let menu = NSMenu()
  380. menu.autoenablesItems = false
  381. if #available(macOS 12, *), !appleConfig.displays.isEmpty {
  382. let item = NSMenuItem()
  383. let title = NSLocalizedString("Display", comment: "VMDisplayAppleWindowController")
  384. let isCurrent = self is VMDisplayAppleDisplayWindowController
  385. item.title = title
  386. item.isEnabled = !isCurrent
  387. item.state = isCurrent ? .on : .off
  388. item.target = self
  389. item.action = #selector(showWindowFromDisplay)
  390. menu.addItem(item)
  391. }
  392. for i in appleConfig.serials.indices {
  393. if appleConfig.serials[i].mode != .builtin || appleConfig.serials[i].terminal == nil {
  394. continue
  395. }
  396. let item = NSMenuItem()
  397. let format = NSLocalizedString("Serial %lld", comment: "VMDisplayAppleWindowController")
  398. let title = String.localizedStringWithFormat(format, i + 1)
  399. let isCurrent = (self as? VMDisplayAppleTerminalWindowController)?.index == i
  400. item.title = title
  401. item.isEnabled = !isCurrent
  402. item.state = isCurrent ? .on : .off
  403. item.tag = i
  404. item.target = self
  405. item.action = #selector(showWindowFromSerial)
  406. menu.addItem(item)
  407. }
  408. menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil)
  409. }
  410. @available(macOS 12, *)
  411. @objc private func showWindowFromDisplay(sender: AnyObject) {
  412. if self is VMDisplayAppleDisplayWindowController {
  413. return
  414. }
  415. if let window = primaryWindow, window is VMDisplayAppleDisplayWindowController {
  416. window.showWindow(self)
  417. }
  418. }
  419. @objc private func showWindowFromSerial(sender: AnyObject) {
  420. let item = sender as! NSMenuItem
  421. let id = item.tag
  422. let secondaryWindows: [VMDisplayWindowController]
  423. if let primaryWindow = primaryWindow {
  424. if (primaryWindow as? VMDisplayAppleTerminalWindowController)?.index == id {
  425. primaryWindow.showWindow(self)
  426. return
  427. }
  428. secondaryWindows = primaryWindow.secondaryWindows
  429. } else {
  430. secondaryWindows = self.secondaryWindows
  431. }
  432. for window in secondaryWindows {
  433. if (window as? VMDisplayAppleTerminalWindowController)?.index == id {
  434. window.showWindow(self)
  435. return
  436. }
  437. }
  438. // create new serial window
  439. let vc = VMDisplayAppleTerminalWindowController(secondaryForIndex: id, vm: appleVM)
  440. registerSecondaryWindow(vc)
  441. vc.showWindow(self)
  442. }
  443. }
  444. // https://www.avanderlee.com/swift/auto-layout-programmatically/
  445. fileprivate extension NSView {
  446. /// Returns a collection of constraints to anchor the bounds of the current view to the given view.
  447. ///
  448. /// - Parameter view: The view to anchor to.
  449. /// - Returns: The layout constraints needed for this constraint.
  450. func constraintsForAnchoringTo(boundsOf view: NSView) -> [NSLayoutConstraint] {
  451. return [
  452. topAnchor.constraint(equalTo: view.topAnchor),
  453. leadingAnchor.constraint(equalTo: view.leadingAnchor),
  454. view.bottomAnchor.constraint(equalTo: bottomAnchor),
  455. view.trailingAnchor.constraint(equalTo: trailingAnchor)
  456. ]
  457. }
  458. }
  459. // https://stackoverflow.com/a/41387514/13914748
  460. fileprivate extension NSView {
  461. /// Get `NSImage` representation of the view.
  462. ///
  463. /// - Returns: `NSImage` of view
  464. func image() -> NSImage {
  465. let imageRepresentation = bitmapImageRepForCachingDisplay(in: bounds)!
  466. cacheDisplay(in: bounds, to: imageRepresentation)
  467. return NSImage(cgImage: imageRepresentation.cgImage!, size: bounds.size)
  468. }
  469. }