Files
UTM/Services/UTMAppleUSBDevice.swift
osy 800dfc1f14 apple: add USB passthrough on macOS 27
Apple Virtualization VMs could not share host USB devices. macOS 27 adds
public APIs for this: AccessoryAccess.framework reports devices the user
assigned to UTM in the system "Virtual Machine Accessories" menu bar item and
VZUSBPassthroughDevice captures them, so the private API approach from the
abandoned branch is no longer needed.

Devices are reported once per process to UTMAppleUSBManager. A newly assigned
device is connected to the only running VM, or to the VM whose window is in
front when several are running, so the user does not have to select it twice.
The USB menu of the Apple VM window lists assigned devices and can move a
device from another VM after confirmation. A device the user disconnects from
the menu stays assigned to UTM but is not connected again until asked, since
the system reports it again right after it is detached. The VM label is set so
the system menu names the VM.

Connected devices are recorded with their UUID when the VM state is saved and
added to the configuration before the state is restored, so the guest keeps
the device across a suspend. A device that is missing at restore is skipped
and the guest sees it unplugged; the restore itself still succeeds.

Requires the com.apple.developer.accessory-access.usb entitlement.

Assisted-by: Claude:claude-fable-5-1
2026-09-17 22:15:30 +02:00

144 lines
5.0 KiB
Swift

//
// Copyright © 2026 Turing Software, LLC. 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 IOKit
import AccessoryAccess
/// A host USB device that can be passed through to an Apple Virtualization guest.
///
/// `AAUSBAccessory` only exposes the raw descriptors, so the user visible properties are
/// read from the IORegistry entry of the device when it is discovered.
@available(macOS 27, *)
final class UTMAppleUSBDevice: Hashable {
/// The accessory to capture with Virtualization
let accessory: AAUSBAccessory
/// IORegistry ID which uniquely identifies the device while it is plugged in
let registryID: UInt64
/// USB vendor ID
let usbVendorId: Int
/// USB product ID
let usbProductId: Int
/// USB manufacturer if available
let usbManufacturerName: String?
/// USB product if available
let usbProductName: String?
/// USB device serial if available
let usbSerial: String?
/// USB bus number
let usbBusNumber: Int
/// USB port number
let usbPortNumber: Int
/// A user-readable description of the device
var name: String {
if let usbProductName = usbProductName {
return "\(usbProductName) (\(usbBusNumber):\(usbPortNumber))"
} else {
return String.localizedStringWithFormat(NSLocalizedString("USB Device %04X:%04X", comment: "UTMAppleUSBDevice"), usbVendorId, usbProductId)
}
}
/// Identity used to remember devices across a saved virtual machine state
var registryDevice: UTMRegistryEntry.USBDevice {
UTMRegistryEntry.USBDevice(vendorId: usbVendorId, productId: usbProductId, serial: usbSerial)
}
init(accessory: AAUSBAccessory) {
self.accessory = accessory
registryID = accessory.registryID
// see IOUSBDeviceDescriptor
let descriptor = accessory.deviceDescriptorData
usbVendorId = Int(descriptor.littleEndianUInt16(at: 8))
usbProductId = Int(descriptor.littleEndianUInt16(at: 10))
let entry = IOServiceGetMatchingService(kIOMainPortDefault, IORegistryEntryIDMatching(registryID))
defer {
if entry != IO_OBJECT_NULL {
IOObjectRelease(entry)
}
}
usbManufacturerName = Self.property(of: entry, named: "USB Vendor Name") as? String
usbProductName = Self.property(of: entry, named: "USB Product Name") as? String
usbSerial = Self.property(of: entry, named: "USB Serial Number") as? String
let locationID = (Self.property(of: entry, named: "locationID") as? NSNumber)?.uint32Value ?? 0
usbBusNumber = Int(locationID >> 24)
usbPortNumber = Self.portNumber(fromLocationID: locationID)
}
/// The port on the parent hub is the last non-zero nibble of the location ID below the bus number
private static func portNumber(fromLocationID locationID: UInt32) -> Int {
var port = 0
var shift: UInt32 = 20
while shift < 24 {
let nibble = Int((locationID >> shift) & 0xF)
if nibble == 0 {
break
}
port = nibble
if shift == 0 {
break
}
shift -= 4
}
return port
}
private static func property(of entry: io_registry_entry_t, named name: String) -> Any? {
guard entry != IO_OBJECT_NULL else {
return nil
}
return IORegistryEntryCreateCFProperty(entry, name as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue()
}
/// Does this device match an identity saved in the registry?
/// - Parameter other: Saved identity
/// - Returns: true if the vendor ID, product ID, and serial number (when saved) match
func matches(_ other: UTMRegistryEntry.USBDevice) -> Bool {
guard usbVendorId == other.vendorId && usbProductId == other.productId else {
return false
}
if let serial = other.serial, !serial.isEmpty {
return usbSerial == serial
}
return true
}
static func == (lhs: UTMAppleUSBDevice, rhs: UTMAppleUSBDevice) -> Bool {
lhs.registryID == rhs.registryID
}
func hash(into hasher: inout Hasher) {
hasher.combine(registryID)
}
}
private extension Data {
func littleEndianUInt16(at offset: Int) -> UInt16 {
guard count >= offset + 2 else {
return 0
}
return UInt16(self[startIndex + offset]) | (UInt16(self[startIndex + offset + 1]) << 8)
}
}