VMDisplayMetalWindowController.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  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 Carbon.HIToolbox
  17. import CocoaSpiceRenderer
  18. class VMDisplayMetalWindowController: VMDisplayQemuWindowController {
  19. var metalView: VMMetalView!
  20. var renderer: CSRenderer?
  21. @objc fileprivate dynamic weak var vmDisplay: CSDisplayMetal?
  22. @objc fileprivate weak var vmInput: CSInput?
  23. @objc fileprivate weak var vmUsbManager: CSUSBManager?
  24. private var displaySizeObserver: NSKeyValueObservation?
  25. private var displaySize: CGSize = .zero
  26. private var isDisplaySizeDynamic: Bool = false
  27. private var isFullScreen: Bool = false
  28. private let minDynamicSize = CGSize(width: 800, height: 600)
  29. private let resizeTimeoutSecs: Double = 5
  30. private var cancelResize: DispatchWorkItem?
  31. private var localEventMonitor: Any? = nil
  32. private var ctrlKeyDown: Bool = false
  33. private var allUsbDevices: [CSUSBDevice] = []
  34. private var connectedUsbDevices: [CSUSBDevice] = []
  35. // MARK: - User preferences
  36. @Setting("NoCursorCaptureAlert") private var isCursorCaptureAlertShown: Bool = false
  37. @Setting("DisplayFixed") private var isDisplayFixed: Bool = false
  38. @Setting("CtrlRightClick") private var isCtrlRightClick: Bool = false
  39. @Setting("NoUsbPrompt") private var isNoUsbPrompt: Bool = false
  40. @Setting("AlternativeCaptureKey") private var isAlternativeCaptureKey: Bool = false
  41. private var settingObservations = [NSKeyValueObservation]()
  42. // MARK: - Init
  43. override func windowDidLoad() {
  44. metalView = VMMetalView(frame: displayView.bounds)
  45. metalView.autoresizingMask = [.width, .height]
  46. metalView.device = MTLCreateSystemDefaultDevice()
  47. guard let _ = metalView.device else {
  48. showErrorAlert(NSLocalizedString("Metal is not supported on this device. Cannot render display.", comment: "VMDisplayMetalWindowController"))
  49. logger.critical("Cannot find system default Metal device.")
  50. return
  51. }
  52. displayView.addSubview(metalView)
  53. renderer = CSRenderer.init(metalKitView: metalView)
  54. guard let renderer = self.renderer else {
  55. showErrorAlert(NSLocalizedString("Internal error.", comment: "VMDisplayMetalWindowController"))
  56. logger.critical("Failed to create renderer.")
  57. return
  58. }
  59. renderer.mtkView(metalView, drawableSizeWillChange: metalView.drawableSize)
  60. renderer.changeUpscaler(vmQemuConfig?.displayUpscalerValue ?? .linear, downscaler: vmQemuConfig?.displayDownscalerValue ?? .linear)
  61. metalView.delegate = renderer
  62. metalView.inputDelegate = self
  63. settingObservations.append(UserDefaults.standard.observe(\.DisplayFixed, options: .new) { (defaults, change) in
  64. self.displaySizeDidChange(size: self.displaySize)
  65. })
  66. super.windowDidLoad()
  67. }
  68. override func enterLive() {
  69. metalView.isHidden = false
  70. screenshotView.isHidden = true
  71. displaySizeObserver = observe(\.vmDisplay!.displaySize, options: [.initial, .new]) { (_, change) in
  72. guard let size = change.newValue else { return }
  73. self.displaySizeDidChange(size: size)
  74. }
  75. if vmQemuConfig!.shareClipboardEnabled {
  76. UTMPasteboard.general.requestPollingMode(forHashable: self) // start clipboard polling
  77. }
  78. // monitor Cmd+Q and Cmd+W and capture them if needed
  79. localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { event in
  80. if !self.handleCaptureKeys(for: event) {
  81. return event
  82. } else {
  83. return nil
  84. }
  85. }
  86. super.enterLive()
  87. resizeConsoleToolbarItem.isEnabled = false // disable item
  88. }
  89. override func enterSuspended(isBusy busy: Bool) {
  90. if !busy {
  91. metalView.isHidden = true
  92. screenshotView.image = vm.screenshot?.image
  93. screenshotView.isHidden = false
  94. }
  95. if vmQemuConfig!.shareClipboardEnabled {
  96. UTMPasteboard.general.releasePollingMode(forHashable: self) // stop clipboard polling
  97. }
  98. if vm.state == .vmStopped {
  99. connectedUsbDevices.removeAll()
  100. allUsbDevices.removeAll()
  101. }
  102. if let localEventMonitor = self.localEventMonitor {
  103. NSEvent.removeMonitor(localEventMonitor)
  104. self.localEventMonitor = nil
  105. }
  106. releaseMouse()
  107. displaySizeObserver = nil
  108. super.enterSuspended(isBusy: busy)
  109. }
  110. override func captureMouseButtonPressed(_ sender: Any) {
  111. captureMouse()
  112. }
  113. }
  114. // MARK: - SPICE IO
  115. extension VMDisplayMetalWindowController: UTMSpiceIODelegate {
  116. func spiceDidChange(_ input: CSInput) {
  117. vmInput = input
  118. qemuVM.requestInputTablet(!(metalView?.isMouseCaptured ?? false))
  119. }
  120. func spiceDidCreateDisplay(_ display: CSDisplayMetal) {
  121. if display.isPrimaryDisplay {
  122. vmDisplay = display
  123. renderer!.source = vmDisplay
  124. }
  125. }
  126. func spiceDidDestroyDisplay(_ display: CSDisplayMetal) {
  127. //TODO: implement something here
  128. }
  129. func spiceDidChange(_ usbManager: CSUSBManager) {
  130. if usbManager != vmUsbManager {
  131. connectedUsbDevices.removeAll()
  132. allUsbDevices.removeAll()
  133. vmUsbManager = usbManager
  134. usbManager.delegate = self
  135. }
  136. }
  137. func spiceDynamicResolutionSupportDidChange(_ supported: Bool) {
  138. if isDisplaySizeDynamic != supported {
  139. displaySizeDidChange(size: displaySize)
  140. DispatchQueue.main.async {
  141. if supported, let window = self.window {
  142. _ = self.updateGuestResolution(for: window, frameSize: window.frame.size)
  143. }
  144. }
  145. }
  146. isDisplaySizeDynamic = supported
  147. }
  148. }
  149. // MARK: - Screen management
  150. extension VMDisplayMetalWindowController {
  151. fileprivate func displaySizeDidChange(size: CGSize) {
  152. // cancel any pending resize
  153. cancelResize?.cancel()
  154. cancelResize = nil
  155. guard size != .zero else {
  156. logger.debug("Ignoring zero size display")
  157. return
  158. }
  159. DispatchQueue.main.async {
  160. logger.debug("resizing to: (\(size.width), \(size.height))")
  161. guard let window = self.window else {
  162. logger.debug("Invalid window, ignoring size change")
  163. return
  164. }
  165. self.displaySize = size
  166. if self.isFullScreen {
  167. _ = self.updateHostScaling(for: window, frameSize: window.frame.size)
  168. } else {
  169. self.updateHostFrame(forGuestResolution: size)
  170. }
  171. }
  172. }
  173. func windowDidChangeScreen(_ notification: Notification) {
  174. logger.debug("screen changed")
  175. if let vmDisplay = self.vmDisplay {
  176. displaySizeDidChange(size: vmDisplay.displaySize)
  177. }
  178. }
  179. fileprivate func updateHostFrame(forGuestResolution size: CGSize) {
  180. guard let window = window else { return }
  181. guard let vmDisplay = vmDisplay else { return }
  182. let currentScreenScale = window.screen?.backingScaleFactor ?? 1.0
  183. let nativeScale = vmQemuConfig.displayRetina ? 1.0 : currentScreenScale
  184. // change optional scale if needed
  185. if isDisplaySizeDynamic || isDisplayFixed || (!vmQemuConfig.displayRetina && vmDisplay.viewportScale < currentScreenScale) {
  186. vmDisplay.viewportScale = nativeScale
  187. }
  188. let minScaledSize = CGSize(width: size.width * nativeScale / currentScreenScale, height: size.height * nativeScale / currentScreenScale)
  189. let fullContentWidth = size.width * vmDisplay.viewportScale / currentScreenScale
  190. let fullContentHeight = size.height * vmDisplay.viewportScale / currentScreenScale
  191. let contentRect = CGRect(x: window.frame.origin.x,
  192. y: 0,
  193. width: ceil(fullContentWidth),
  194. height: ceil(fullContentHeight))
  195. var windowRect = window.frameRect(forContentRect: contentRect)
  196. windowRect.origin.y = window.frame.origin.y + window.frame.height - windowRect.height
  197. if isDisplaySizeDynamic {
  198. window.contentMinSize = minDynamicSize
  199. window.contentResizeIncrements = NSSize(width: 1, height: 1)
  200. window.setFrame(windowRect, display: false, animate: false)
  201. } else {
  202. window.contentMinSize = minScaledSize
  203. window.contentAspectRatio = size
  204. window.setFrame(windowRect, display: false, animate: true)
  205. }
  206. }
  207. fileprivate func updateHostScaling(for window: NSWindow, frameSize: NSSize) -> NSSize {
  208. guard displaySize != .zero else { return frameSize }
  209. guard let vmDisplay = self.vmDisplay else { return frameSize }
  210. let currentScreenScale = window.screen?.backingScaleFactor ?? 1.0
  211. let targetContentSize = window.contentRect(forFrameRect: CGRect(origin: .zero, size: frameSize)).size
  212. let targetScaleX = targetContentSize.width * currentScreenScale / displaySize.width
  213. let targetScaleY = targetContentSize.height * currentScreenScale / displaySize.height
  214. let targetScale = min(targetScaleX, targetScaleY)
  215. let scaledSize = CGSize(width: displaySize.width * targetScale / currentScreenScale, height: displaySize.height * targetScale / currentScreenScale)
  216. let targetFrameSize = window.frameRect(forContentRect: CGRect(origin: .zero, size: scaledSize)).size
  217. vmDisplay.viewportScale = targetScale
  218. logger.debug("changed scale \(targetScale)")
  219. return targetFrameSize
  220. }
  221. fileprivate func updateGuestResolution(for window: NSWindow, frameSize: NSSize) -> NSSize {
  222. guard let vmDisplay = self.vmDisplay else { return frameSize }
  223. let currentScreenScale = window.screen?.backingScaleFactor ?? 1.0
  224. let nativeScale = vmQemuConfig.displayRetina ? currentScreenScale : 1.0
  225. let targetSize = window.contentRect(forFrameRect: CGRect(origin: .zero, size: frameSize)).size
  226. let targetSizeScaled = vmQemuConfig.displayRetina ? targetSize.applying(CGAffineTransform(scaleX: nativeScale, y: nativeScale)) : targetSize
  227. logger.debug("Requesting resolution: (\(targetSizeScaled.width), \(targetSizeScaled.height))")
  228. let bounds = CGRect(origin: .zero, size: targetSizeScaled)
  229. vmDisplay.requestResolution(bounds)
  230. return frameSize
  231. }
  232. func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize {
  233. guard !self.isDisplaySizeDynamic else {
  234. return frameSize
  235. }
  236. guard !self.isDisplayFixed else {
  237. return frameSize
  238. }
  239. let newSize = updateHostScaling(for: sender, frameSize: frameSize)
  240. if isFullScreen {
  241. return frameSize
  242. } else {
  243. return newSize
  244. }
  245. }
  246. func windowDidEndLiveResize(_ notification: Notification) {
  247. guard self.isDisplaySizeDynamic, let window = self.window else {
  248. return
  249. }
  250. _ = updateGuestResolution(for: window, frameSize: window.frame.size)
  251. cancelResize = DispatchWorkItem {
  252. if let vmDisplay = self.vmDisplay {
  253. self.displaySizeDidChange(size: vmDisplay.displaySize)
  254. }
  255. }
  256. DispatchQueue.main.asyncAfter(deadline: .now() + resizeTimeoutSecs, execute: cancelResize!)
  257. }
  258. func windowDidEnterFullScreen(_ notification: Notification) {
  259. isFullScreen = true
  260. }
  261. func windowDidExitFullScreen(_ notification: Notification) {
  262. isFullScreen = false
  263. }
  264. override func windowDidResignKey(_ notification: Notification) {
  265. releaseMouse()
  266. super.windowDidResignKey(notification)
  267. }
  268. }
  269. // MARK: - Input events
  270. extension VMDisplayMetalWindowController: VMMetalViewInputDelegate {
  271. var shouldUseCmdOptForCapture: Bool {
  272. isAlternativeCaptureKey || NSWorkspace.shared.isVoiceOverEnabled
  273. }
  274. func captureMouse() {
  275. let action = { () -> Void in
  276. self.qemuVM.requestInputTablet(false)
  277. self.metalView?.captureMouse()
  278. self.window?.subtitle = NSLocalizedString("Press \(self.shouldUseCmdOptForCapture ? "⌘+⌥" : "⌃+⌥") to release cursor", comment: "VMDisplayMetalWindowController")
  279. self.window?.makeFirstResponder(self.metalView)
  280. }
  281. if isCursorCaptureAlertShown {
  282. let alert = NSAlert()
  283. alert.messageText = NSLocalizedString("Captured mouse", comment: "VMDisplayMetalWindowController")
  284. alert.informativeText = NSLocalizedString("To release the mouse cursor, press \(self.shouldUseCmdOptForCapture ? "⌘+⌥ (Cmd+Opt)" : "⌃+⌥ (Ctrl+Opt)") at the same time.", comment: "VMDisplayMetalWindowController")
  285. alert.showsSuppressionButton = true
  286. alert.beginSheetModal(for: window!) { _ in
  287. if alert.suppressionButton?.state ?? .off == .on {
  288. self.isCursorCaptureAlertShown = false
  289. }
  290. DispatchQueue.main.async(execute: action)
  291. }
  292. } else {
  293. action()
  294. }
  295. }
  296. func releaseMouse() {
  297. qemuVM.requestInputTablet(true)
  298. metalView?.releaseMouse()
  299. self.window?.subtitle = ""
  300. }
  301. func mouseMove(absolutePoint: CGPoint, button: CSInputButton) {
  302. guard let window = self.window else { return }
  303. let currentScreenScale = window.screen?.backingScaleFactor ?? 1.0
  304. let viewportScale = vmDisplay?.viewportScale ?? 1.0
  305. let frameSize = metalView.frame.size
  306. let newX = absolutePoint.x * currentScreenScale / viewportScale
  307. let newY = (frameSize.height - absolutePoint.y) * currentScreenScale / viewportScale
  308. let point = CGPoint(x: newX, y: newY)
  309. logger.trace("move cursor: cocoa (\(absolutePoint.x), \(absolutePoint.y)), native (\(newX), \(newY))")
  310. vmInput?.sendMouseMotion(button, point: point)
  311. vmDisplay?.forceCursorPosition(point) // required to show cursor on screen
  312. }
  313. func mouseMove(relativePoint: CGPoint, button: CSInputButton) {
  314. let translated = CGPoint(x: relativePoint.x, y: -relativePoint.y)
  315. vmInput?.sendMouseMotion(button, point: translated)
  316. }
  317. private func modifyMouseButton(_ button: CSInputButton) -> CSInputButton {
  318. let buttonMod: CSInputButton
  319. if button.contains(.left) && ctrlKeyDown && isCtrlRightClick {
  320. buttonMod = button.subtracting(.left).union(.right)
  321. } else {
  322. buttonMod = button
  323. }
  324. return buttonMod
  325. }
  326. func mouseDown(button: CSInputButton) {
  327. vmInput?.sendMouseButton(modifyMouseButton(button), pressed: true)
  328. }
  329. func mouseUp(button: CSInputButton) {
  330. vmInput?.sendMouseButton(modifyMouseButton(button), pressed: false)
  331. }
  332. func mouseScroll(dy: CGFloat, button: CSInputButton) {
  333. let scrollInvert = vmQemuConfig?.inputScrollInvert ?? false
  334. let scrollDy = scrollInvert ? -dy : dy
  335. vmInput?.sendMouseScroll(.smooth, button: button, dy: scrollDy)
  336. }
  337. private func sendExtendedKey(_ button: CSInputKey, keyCode: Int) {
  338. if (keyCode & 0xFF00) == 0xE000 {
  339. vmInput?.send(button, code: Int32(0x100 | (keyCode & 0xFF)))
  340. } else if keyCode >= 0x100 {
  341. logger.warning("ignored invalid keycode \(keyCode)");
  342. } else {
  343. vmInput?.send(button, code: Int32(keyCode))
  344. }
  345. }
  346. func keyDown(scanCode: Int) {
  347. if (scanCode & 0xFF) == 0x1D { // Ctrl
  348. ctrlKeyDown = true
  349. }
  350. sendExtendedKey(.press, keyCode: scanCode)
  351. }
  352. func keyUp(scanCode: Int) {
  353. if (scanCode & 0xFF) == 0x1D { // Ctrl
  354. ctrlKeyDown = false
  355. }
  356. sendExtendedKey(.release, keyCode: scanCode)
  357. }
  358. private func handleCaptureKeys(for event: NSEvent) -> Bool {
  359. // if captured we route all keyevents to view
  360. if let metalView = metalView, metalView.isMouseCaptured {
  361. if event.type == .keyDown {
  362. metalView.keyDown(with: event)
  363. } else if event.type == .keyUp {
  364. metalView.keyUp(with: event)
  365. }
  366. return true
  367. }
  368. if event.modifierFlags.contains(.command) && event.type == .keyUp {
  369. // for some reason, macOS doesn't like to send Cmd+KeyUp
  370. metalView.keyUp(with: event)
  371. return false
  372. }
  373. return false
  374. }
  375. }
  376. // MARK: - USB handling
  377. extension VMDisplayMetalWindowController: CSUSBManagerDelegate {
  378. func spiceUsbManager(_ usbManager: CSUSBManager, deviceError error: String, for device: CSUSBDevice) {
  379. logger.debug("USB device error: (\(device)) \(error)")
  380. DispatchQueue.main.async {
  381. self.showErrorAlert(error)
  382. }
  383. }
  384. func spiceUsbManager(_ usbManager: CSUSBManager, deviceAttached device: CSUSBDevice) {
  385. logger.debug("USB device attached: \(device)")
  386. if !isNoUsbPrompt {
  387. DispatchQueue.main.async {
  388. if self.window!.isKeyWindow {
  389. self.showConnectPrompt(for: device)
  390. }
  391. }
  392. }
  393. }
  394. func spiceUsbManager(_ usbManager: CSUSBManager, deviceRemoved device: CSUSBDevice) {
  395. logger.debug("USB device removed: \(device)")
  396. if let i = connectedUsbDevices.firstIndex(of: device) {
  397. connectedUsbDevices.remove(at: i)
  398. }
  399. }
  400. func showConnectPrompt(for usbDevice: CSUSBDevice) {
  401. guard let usbManager = vmUsbManager else {
  402. logger.error("cannot get usb manager")
  403. return
  404. }
  405. let alert = NSAlert()
  406. alert.alertStyle = .informational
  407. alert.messageText = NSLocalizedString("USB Device", comment: "VMDisplayMetalWindowController")
  408. alert.informativeText = NSLocalizedString("Would you like to connect '\(usbDevice.name ?? usbDevice.description)' to this virtual machine?", comment: "VMDisplayMetalWindowController")
  409. alert.showsSuppressionButton = true
  410. alert.addButton(withTitle: NSLocalizedString("Confirm", comment: "VMDisplayMetalWindowController"))
  411. alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "VMDisplayMetalWindowController"))
  412. alert.beginSheetModal(for: window!) { response in
  413. if let suppressionButton = alert.suppressionButton,
  414. suppressionButton.state == .on {
  415. self.isNoUsbPrompt = true
  416. }
  417. guard response == .alertFirstButtonReturn else {
  418. return
  419. }
  420. DispatchQueue.global(qos: .background).async {
  421. usbManager.connectUsbDevice(usbDevice) { (result, message) in
  422. DispatchQueue.main.async {
  423. if let msg = message {
  424. self.showErrorAlert(msg)
  425. }
  426. if result {
  427. self.connectedUsbDevices.append(usbDevice)
  428. }
  429. }
  430. }
  431. }
  432. }
  433. }
  434. }
  435. extension VMDisplayMetalWindowController {
  436. @IBAction override func usbButtonPressed(_ sender: Any) {
  437. let menu = NSMenu()
  438. menu.autoenablesItems = false
  439. let item = NSMenuItem()
  440. item.title = NSLocalizedString("Querying USB devices...", comment: "VMDisplayMetalWindowController")
  441. item.isEnabled = false
  442. menu.addItem(item)
  443. DispatchQueue.global(qos: .userInitiated).async {
  444. let devices = self.vmUsbManager?.usbDevices ?? []
  445. DispatchQueue.main.async {
  446. self.updateUsbDevicesMenu(menu, devices: devices)
  447. }
  448. }
  449. menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil)
  450. }
  451. func updateUsbDevicesMenu(_ menu: NSMenu, devices: [CSUSBDevice]) {
  452. allUsbDevices = devices
  453. menu.removeAllItems()
  454. if devices.count == 0 {
  455. let item = NSMenuItem()
  456. item.title = NSLocalizedString("No USB devices detected.", comment: "VMDisplayMetalWindowController")
  457. item.isEnabled = false
  458. menu.addItem(item)
  459. }
  460. for (i, device) in devices.enumerated() {
  461. let item = NSMenuItem()
  462. let canRedirect = vmUsbManager?.canRedirectUsbDevice(device, errorMessage: nil) ?? false
  463. let isConnected = vmUsbManager?.isUsbDeviceConnected(device) ?? false
  464. let isConnectedToSelf = connectedUsbDevices.contains(device)
  465. item.title = device.name ?? device.description
  466. item.isEnabled = canRedirect && (isConnectedToSelf || !isConnected);
  467. item.state = isConnectedToSelf ? .on : .off;
  468. item.tag = i
  469. item.target = self
  470. item.action = isConnectedToSelf ? #selector(disconnectUsbDevice) : #selector(connectUsbDevice)
  471. menu.addItem(item)
  472. }
  473. menu.update()
  474. }
  475. @objc func connectUsbDevice(sender: AnyObject) {
  476. guard let menu = sender as? NSMenuItem else {
  477. logger.error("wrong sender for connectUsbDevice")
  478. return
  479. }
  480. guard let usbManager = vmUsbManager else {
  481. logger.error("cannot get usb manager")
  482. return
  483. }
  484. let device = allUsbDevices[menu.tag]
  485. DispatchQueue.global(qos: .background).async {
  486. usbManager.connectUsbDevice(device) { (result, message) in
  487. DispatchQueue.main.async {
  488. if let msg = message {
  489. self.showErrorAlert(msg)
  490. }
  491. if result {
  492. self.connectedUsbDevices.append(device)
  493. }
  494. }
  495. }
  496. }
  497. }
  498. @objc func disconnectUsbDevice(sender: AnyObject) {
  499. guard let menu = sender as? NSMenuItem else {
  500. logger.error("wrong sender for disconnectUsbDevice")
  501. return
  502. }
  503. guard let usbManager = vmUsbManager else {
  504. logger.error("cannot get usb manager")
  505. return
  506. }
  507. let device = allUsbDevices[menu.tag]
  508. DispatchQueue.global(qos: .background).async {
  509. usbManager.disconnectUsbDevice(device) { (result, message) in
  510. DispatchQueue.main.async {
  511. if let msg = message {
  512. self.showErrorAlert(msg)
  513. }
  514. if result {
  515. self.connectedUsbDevices.removeAll(where: { $0 == device })
  516. }
  517. }
  518. }
  519. }
  520. }
  521. }