Browse Source

display(macOS): auto-connect saved USB devices

Resolves #3400
osy 1 week ago
parent
commit
468cdafa02

+ 56 - 12
Platform/macOS/Display/VMDisplayQemuDisplayController.swift

@@ -275,6 +275,7 @@ extension VMDisplayQemuWindowController: UTMSpiceIODelegate {
             vmUsbManager = usbManager
             if let usbManager = usbManager {
                 usbManager.delegate = self
+                autoConnectUsbDevices()
             }
         }
         for subwindow in secondaryWindows {
@@ -415,10 +416,28 @@ extension VMDisplayQemuWindowController {
             item.title = device.name ?? device.description
             let blocked = usbBlockList.contains { (usbVid, usbPid) in usbVid == device.usbVendorId && usbPid == device.usbProductId }
             item.isEnabled = !blocked && canRedirect && (isConnectedToSelf || !isConnected)
-            item.state = isConnectedToSelf ? .on : .off;
+            item.state = isConnectedToSelf ? .on : .off
             item.tag = i
-            item.target = self
-            item.action = isConnectedToSelf ? #selector(disconnectUsbDevice) : #selector(connectUsbDevice)
+
+            let submenu = NSMenu()
+            let connectItem = NSMenuItem()
+            connectItem.title = isConnectedToSelf ? NSLocalizedString("Disconnect…", comment: "VMDisplayQemuDisplayController") : NSLocalizedString("Connect…", comment: "VMDisplayQemuDisplayController")
+            connectItem.isEnabled = !blocked && canRedirect && (isConnectedToSelf || !isConnected)
+            connectItem.tag = i
+            connectItem.target = self
+            connectItem.action = isConnectedToSelf ? #selector(disconnectUsbDevice) : #selector(connectUsbDevice)
+            submenu.addItem(connectItem)
+
+            let autoItem = NSMenuItem()
+            autoItem.title = NSLocalizedString("Auto connect on start", comment: "VMDisplayQemuDisplayController")
+            autoItem.isEnabled = !blocked && canRedirect
+            autoItem.state = isAutoConnect(device) ? .on : .off
+            autoItem.tag = i
+            autoItem.target = self
+            autoItem.action = #selector(setAutoConnect)
+            submenu.addItem(autoItem)
+
+            item.submenu = submenu
             menu.addItem(item)
         }
         menu.update()
@@ -435,15 +454,11 @@ extension VMDisplayQemuWindowController {
         }
         let device = allUsbDevices[menu.tag]
         Task.detached {
-            do {
+            self.withErrorAlert {
                 try await usbManager.connectUsbDevice(device)
                 await MainActor.run {
                     self.connectedUsbDevices.append(device)
                 }
-            } catch {
-                await MainActor.run {
-                    self.showErrorAlert(error.localizedDescription)
-                }
             }
         }
     }
@@ -460,11 +475,40 @@ extension VMDisplayQemuWindowController {
         let device = allUsbDevices[menu.tag]
         connectedUsbDevices.removeAll(where: { $0 == device })
         Task.detached {
-            do {
+            self.withErrorAlert {
                 try await usbManager.disconnectUsbDevice(device)
-            } catch {
-                await MainActor.run {
-                    self.showErrorAlert(error.localizedDescription)
+            }
+        }
+    }
+
+    func isAutoConnect(_ device: CSUSBDevice) -> Bool {
+        return qemuVM.isAutoConnect(for: device)
+    }
+
+    @objc func setAutoConnect(sender: AnyObject) {
+        guard let menu = sender as? NSMenuItem else {
+            logger.error("wrong sender for autoConnect")
+            return
+        }
+        let device = allUsbDevices[menu.tag]
+        qemuVM.setAutoConnect(!qemuVM.isAutoConnect(for: device), for: device)
+    }
+
+    func autoConnectUsbDevices() {
+        guard let usbManager = vmUsbManager else {
+            return
+        }
+        DispatchQueue.global(qos: .userInitiated).async {
+            guard let devices = self.vmUsbManager?.usbDevices else {
+                return
+            }
+            let filtered = devices.filter({ self.isAutoConnect($0) })
+            for device in filtered {
+                self.withErrorAlert {
+                    try await usbManager.connectUsbDevice(device)
+                    await MainActor.run {
+                        self.connectedUsbDevices.append(device)
+                    }
                 }
             }
         }

+ 1 - 1
Platform/macOS/Display/VMDisplayWindowController.swift

@@ -240,7 +240,7 @@ class VMDisplayWindowController: NSWindowController, UTMVirtualMachineDelegate {
         }
     }
     
-    @nonobjc func withErrorAlert(_ callback: @escaping () async throws -> Void) {
+    @nonobjc nonisolated func withErrorAlert(_ callback: @escaping () async throws -> Void) {
         Task.detached(priority: .background) { [self] in
             do {
                 try await callback()

+ 81 - 0
Services/UTMUSBManager.swift

@@ -0,0 +1,81 @@
+//
+// Copyright © 2025 osy. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import CocoaSpice
+
+final class UTMUSBManager {
+    struct USBDevice: Codable, Hashable {
+        var usbVendorId: Int
+        var usbProductId: Int
+        var usbManufacturerName: String?
+        var usbProductName: String?
+        var usbSerial: String?
+
+        fileprivate init(_ device: CSUSBDevice) {
+            usbVendorId = device.usbVendorId
+            usbProductId = device.usbProductId
+            usbManufacturerName = device.usbManufacturerName
+            usbProductName = device.usbProductName
+            usbSerial = device.usbSerial
+        }
+    }
+
+    static let shared = UTMUSBManager()
+    @Setting("SavedUsbDevices") private var savedUsbDevices: Data? = nil
+    lazy var usbDevices: [USBDevice: UUID] = loadUsbDevices() {
+        didSet {
+            saveUsbDevices(usbDevices)
+        }
+    }
+
+    private init() {}
+
+    private func loadUsbDevices() -> [USBDevice: UUID] {
+        let decoder = PropertyListDecoder()
+        if let data = savedUsbDevices {
+            if let decoded = try? decoder.decode([USBDevice: UUID].self, from: data) {
+                return decoded
+            }
+        }
+        // default entry
+        return [:]
+    }
+
+    private func saveUsbDevices(_ usbDevices: [USBDevice: UUID]) {
+        let encoder = PropertyListEncoder()
+        encoder.outputFormat = .binary
+        if let data = try? encoder.encode(usbDevices) {
+            savedUsbDevices = data
+        }
+    }
+}
+
+extension UTMVirtualMachine {
+    func isAutoConnect(for device: CSUSBDevice) -> Bool {
+        let usbDevice = UTMUSBManager.USBDevice(device)
+        return UTMUSBManager.shared.usbDevices[usbDevice] == self.id
+    }
+
+    func setAutoConnect(_ autoConnect: Bool, for device: CSUSBDevice) {
+        let usbDevice = UTMUSBManager.USBDevice(device)
+        if autoConnect {
+            UTMUSBManager.shared.usbDevices[usbDevice] = self.id
+        } else {
+            UTMUSBManager.shared.usbDevices.removeValue(forKey: usbDevice)
+        }
+    }
+}

+ 4 - 0
UTM.xcodeproj/project.pbxproj

@@ -650,6 +650,7 @@
 		CE612AC624D3B50700FA6300 /* VMDisplayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE612AC524D3B50700FA6300 /* VMDisplayWindowController.swift */; };
 		CE65BABF26A4D8DD0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401FDA5269D44E400265F0D /* VMConfigDisplayConsoleView.swift */; };
 		CE65BAC026A4D8DE0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401FDA5269D44E400265F0D /* VMConfigDisplayConsoleView.swift */; };
+		CE6804802E493D71001671E9 /* UTMUSBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68047D2E493D71001671E9 /* UTMUSBManager.swift */; };
 		CE68E5442E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; };
 		CE68E5452E3912E0006B3645 /* VMKeyboardShortcutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */; };
 		CE68E5482E3C3E0A006B3645 /* VMWizardOSClassicMacView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE68E5472E3C3E0A006B3645 /* VMWizardOSClassicMacView.swift */; };
@@ -1982,6 +1983,7 @@
 		CE611BEA29F50D3E001817BC /* VMReleaseNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMReleaseNotesView.swift; sourceTree = "<group>"; };
 		CE612AC524D3B50700FA6300 /* VMDisplayWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayWindowController.swift; sourceTree = "<group>"; };
 		CE66450C2269313200B0849A /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
+		CE68047D2E493D71001671E9 /* UTMUSBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMUSBManager.swift; sourceTree = "<group>"; };
 		CE68E5422E3912E0006B3645 /* VMKeyboardShortcutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMKeyboardShortcutsView.swift; sourceTree = "<group>"; };
 		CE68E5472E3C3E0A006B3645 /* VMWizardOSClassicMacView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMWizardOSClassicMacView.swift; sourceTree = "<group>"; };
 		CE6B240A25F1F3CE0020D43E /* main.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = main.c; sourceTree = "<group>"; };
@@ -2920,6 +2922,7 @@
 				E2D64BC8241DB24B0034E0C6 /* UTMSpiceIO.m */,
 				E2D64BE0241EAEBE0034E0C6 /* UTMSpiceIODelegate.h */,
 				845F95E22A57628400A016D7 /* UTMSWTPM.swift */,
+				CE68047D2E493D71001671E9 /* UTMUSBManager.swift */,
 				CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */,
 				CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */,
 				841E999728AC817D003C6CB6 /* UTMQemuVirtualMachine.swift */,
@@ -3992,6 +3995,7 @@
 				843BF82A28441FAF0029D60D /* QEMUConstantGenerated.swift in Sources */,
 				848A98B4286A1215006F0550 /* UTMAppleConfigurationVirtualization.swift in Sources */,
 				843BF842284555E70029D60D /* UTMQemuConfigurationPortForward.swift in Sources */,
+				CE6804802E493D71001671E9 /* UTMUSBManager.swift in Sources */,
 				CEF0306F26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */,
 				83034C0926AB630F006B4BAF /* UTMPendingVMView.swift in Sources */,
 				84909A8F27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,

+ 1 - 1
UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -16,7 +16,7 @@
       "location" : "https://github.com/utmapp/CocoaSpice.git",
       "state" : {
         "branch" : "main",
-        "revision" : "ac641bd7b88e14b4107dcdb508d9779c49b69617"
+        "revision" : "5e8a39421221cafaadf05f4e2f0155b1a540fc7f"
       }
     },
     {