utmctl: implement USB commands

Resolves #5157
This commit is contained in:
osy 2023-09-04 20:49:43 -07:00
parent 1193b80725
commit 8d7b51b878
2 changed files with 149 additions and 1 deletions

View file

@ -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 {}

View file

@ -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.")