Просмотр исходного кода

utmctl: implement USB commands

Resolves #5157
osy 1 год назад
Родитель
Сommit
8d7b51b878
2 измененных файлов с 149 добавлено и 1 удалено
  1. 18 0
      Scripting/UTMScripting.swift
  2. 131 1
      utmctl/UTMCtl.swift

+ 18 - 0
Scripting/UTMScripting.swift

@@ -21,6 +21,7 @@ public enum UTMScripting: String {
     case guestFile = "guest file"
     case guestProcess = "guest process"
     case serialPort = "serial port"
+    case usbDevice = "usb device"
     case virtualMachine = "virtual machine"
 }
 
@@ -161,6 +162,7 @@ import ScriptingBridge
     @objc optional func virtualMachines() -> SBElementArray
     @objc optional var autoTerminate: Bool { get } // Auto terminate the application when all windows are closed?
     @objc optional func setAutoTerminate(_ autoTerminate: Bool) // Auto terminate the application when all windows are closed?
+    @objc optional func usbDevices() -> SBElementArray
 }
 extension SBApplication: UTMScriptingApplication {}
 
@@ -204,6 +206,8 @@ extension SBObject: UTMScriptingWindow {}
     @objc optional func startSaving(_ saving: Bool) // Start a virtual machine or resume a suspended virtual machine.
     @objc optional func suspendSaving(_ saving: Bool) // Suspend a running virtual machine to memory.
     @objc optional func stopBy(_ by: UTMScriptingStopMethod) // Shuts down a running virtual machine.
+    @objc optional func delete() // Delete a virtual machine. All data will be deleted, there is no confirmation!
+    @objc optional func duplicateWithProperties(_ withProperties: [AnyHashable : Any]!) // Copy an virtual machine and all its data.
     @objc optional func openFileAt(_ at: String!, for for_: UTMScriptingOpenMode, updating: Bool) -> UTMScriptingGuestFile // Open a file on the guest. You must close the file when you are done to prevent leaking guest resources.
     @objc optional func executeAt(_ at: String!, withArguments: [String]!, withEnvironment: [String]!, usingInput: String!, base64Encoding: Bool, outputCapturing: Bool) -> UTMScriptingGuestProcess // Execute a command or script on the guest.
     @objc optional func queryIp() -> [Any] // Query the guest for all IP addresses on its network interfaces (excluding loopback).
@@ -211,6 +215,7 @@ extension SBObject: UTMScriptingWindow {}
     @objc optional func guestFiles() -> SBElementArray
     @objc optional func guestProcesses() -> SBElementArray
     @objc optional var configuration: Any { get } // The configuration of the virtual machine.
+    @objc optional func usbDevices() -> SBElementArray
 }
 extension SBObject: UTMScriptingVirtualMachine {}
 
@@ -241,3 +246,16 @@ extension SBObject: UTMScriptingGuestFile {}
 }
 extension SBObject: UTMScriptingGuestProcess {}
 
+// MARK: UTMScriptingUsbDevice
+@objc public protocol UTMScriptingUsbDevice: SBObjectProtocol, UTMScriptingGenericMethods {
+    @objc optional func id() -> Int // A unique identifier corrosponding to the USB bus and port number.
+    @objc optional var name: String { get } // The name of the USB device.
+    @objc optional var manufacturerName: String { get } // The product name described by the iManufacturer descriptor.
+    @objc optional var productName: String { get } // The product name described by the iProduct descriptor.
+    @objc optional var vendorId: Int { get } // The vendor ID described by the idVendor descriptor.
+    @objc optional var productId: Int { get } // The product ID described by the idProduct descriptor.
+    @objc optional func connectTo(_ to: UTMScriptingVirtualMachine!) // Connect a USB device to a running VM and remove it from the host.
+    @objc optional func disconnect() // Disconnect a USB device from the guest and re-assign it to the host.
+}
+extension SBObject: UTMScriptingUsbDevice {}
+

+ 131 - 1
utmctl/UTMCtl.swift

@@ -24,7 +24,20 @@ struct UTMCtl: ParsableCommand {
     static var configuration = CommandConfiguration(
         commandName: "utmctl",
         abstract: "CLI tool for controlling UTM virtual machines.",
-        subcommands: [List.self, Status.self, Start.self, Suspend.self, Stop.self, Attach.self, File.self, Exec.self, IPAddress.self, Clone.self, Delete.self]
+        subcommands: [
+            List.self,
+            Status.self,
+            Start.self,
+            Suspend.self,
+            Stop.self,
+            Attach.self,
+            File.self,
+            Exec.self,
+            IPAddress.self,
+            Clone.self,
+            Delete.self,
+            USB.self
+        ]
     )
 }
 
@@ -123,11 +136,15 @@ extension UTMCtl {
     enum APIError: Error, LocalizedError {
         case applicationNotFound
         case virtualMachineNotFound
+        case invalidIdentifier(String)
+        case deviceNotFound
         
         var errorDescription: String? {
             switch self {
             case .applicationNotFound: return "Application not found."
             case .virtualMachineNotFound: return "Virtual machine not found."
+            case .invalidIdentifier(let identifier): return "Identifier '\(identifier)' is invalid."
+            case .deviceNotFound: return "Device not found."
             }
         }
     }
@@ -505,6 +522,119 @@ extension UTMCtl {
     }
 }
 
+extension UTMCtl {
+    struct USB: ParsableCommand {
+        static var configuration = CommandConfiguration(
+            abstract: "USB device handling.",
+            subcommands: [USBList.self, USBConnect.self, USBDisconnect.self]
+        )
+        
+        /// Find a USB device using an identifier
+        /// - Parameters:
+        ///   - identifier: Either VID:PID or a location
+        ///   - application: Scripting application
+        /// - Returns: USB device
+        static func usbDevice(forIdentifier identifier: String, in application: UTMScriptingApplication) throws -> UTMScriptingUsbDevice {
+            let parts = identifier.split(separator: ":")
+            if parts.count == 2 {
+                let vid = Int(parts[0], radix: 16)
+                let pid = Int(parts[1], radix: 16)
+                if let vid = vid, let pid = pid {
+                    return try usbDevice(forVid: vid, pid: pid, in: application)
+                }
+            }
+            if let location = Int(identifier, radix: 10) {
+                return try usbDevice(forLocation: location, in: application)
+            }
+            throw APIError.invalidIdentifier(identifier)
+        }
+        
+        static private func usbDevice(forVid vid: Int, pid: Int, in application: UTMScriptingApplication) throws -> UTMScriptingUsbDevice {
+            if let list = application.usbDevices!() as? [UTMScriptingUsbDevice] {
+                if let device = list.first(where: { $0.vendorId == vid && $0.productId == pid }) {
+                    return device
+                }
+            }
+            throw APIError.deviceNotFound
+        }
+        
+        static private func usbDevice(forLocation location: Int, in application: UTMScriptingApplication) throws -> UTMScriptingUsbDevice {
+            if let list = application.usbDevices!() as? [UTMScriptingUsbDevice] {
+                if let device = list.first(where: { $0.id!() == location }) {
+                    return device
+                }
+            }
+            throw APIError.deviceNotFound
+        }
+    }
+    
+    struct USBList: UTMAPICommand {
+        static var configuration = CommandConfiguration(
+            commandName: "list",
+            abstract: "List connected devices."
+        )
+        
+        @OptionGroup var environment: EnvironmentOptions
+        
+        func run(with application: UTMScriptingApplication) throws {
+            if let list = application.usbDevices!() as? [UTMScriptingUsbDevice] {
+                printResponse(list)
+            }
+        }
+        
+        func printResponse(_ response: [UTMScriptingUsbDevice]) {
+            guard !response.isEmpty else {
+                print("No devices found. Make sure a USB sharing enabled VM is running.")
+                return
+            }
+            print("Name                             VID :PID  Location")
+            for entry in response {
+                let name = entry.name!.padding(toLength: 32, withPad: " ", startingAt: 0)
+                let vid = String(format: "%04X", entry.vendorId!)
+                let pid = String(format: "%04X", entry.productId!)
+                print("\(name) \(vid):\(pid) \(entry.id!())")
+            }
+        }
+    }
+    
+    struct USBConnect: UTMAPICommand {
+        static var configuration = CommandConfiguration(
+            commandName: "connect",
+            abstract: "Connect a USB device to a virtual machine."
+        )
+        
+        @OptionGroup var environment: EnvironmentOptions
+        
+        @OptionGroup var identifer: VMIdentifier
+        
+        @Argument(help: "Device identifier either as a VID:PID pair (e.g. DEAD:BEEF) or a location (e.g. 4).")
+        var device: String
+        
+        func run(with application: UTMScriptingApplication) throws {
+            let vm = try virtualMachine(forIdentifier: identifer, in: application)
+            let device = try USB.usbDevice(forIdentifier: device, in: application)
+            device.connectTo!(vm)
+        }
+    }
+    
+    struct USBDisconnect: UTMAPICommand {
+        static var configuration = CommandConfiguration(
+            commandName: "disconnect",
+            abstract: "Disconnect a USB device from a virtual machine."
+        )
+        
+        @OptionGroup var environment: EnvironmentOptions
+        
+        @Argument(help: "Device identifier either as a VID:PID pair (e.g. DEAD:BEEF) or a location (e.g. 4).")
+        var device: String
+        
+        func run(with application: UTMScriptingApplication) throws {
+            let device = try USB.usbDevice(forIdentifier: device, in: application)
+            device.disconnect!()
+        }
+    }
+}
+
 extension UTMCtl {
     struct VMIdentifier: ParsableArguments {
         @Argument(help: "Either the UUID or the complete name of the virtual machine.")