Skip to content

Instantly share code, notes, and snippets.

@JJTech0130
Created March 7, 2026 09:17
Show Gist options
  • Select an option

  • Save JJTech0130/fae6b6ee6ae4232172a9188fb199d5d9 to your computer and use it in GitHub Desktop.

Select an option

Save JJTech0130/fae6b6ee6ae4232172a9188fb199d5d9 to your computer and use it in GitHub Desktop.
Creating fake/virtual USB devices on macOS using IOUSBHostControllerInterface
// ConsumerKeys.swift
// Boot-protocol keyboard that reports keycodes in Consumer page (0x0C).
// iOS maps consumer page usages received this way to system actions.
import Foundation
public final class ConsumerKeys: SyntheticHID {
// MARK: - Consumer usages sent as boot-keyboard keycodes (page 0x0C, ≤ 0xFF)
public enum Key: UInt8 {
case acHome = 0x40 // Consumer "Menu" → iOS home button
case mute = 0xE2
case volumeUp = 0xE9
case volumeDown = 0xEA
case playPause = 0xCD
case nextTrack = 0xB5
case prevTrack = 0xB6
}
// MARK: - Init
public init() {
super.init(
reportDescriptor: Self.reportDescriptor,
reportSize: 8,
vendorID: 0x05AC,
productID: 0x0002,
manufacturer: "Apple Inc.",
product: "Virtual Consumer Control",
interfaceSubClass: 0x01,
interfaceProtocol: 0x01)
}
// MARK: - Key injection
public func keyDown(_ key: Key) {
sendReport([0, 0, key.rawValue, 0, 0, 0, 0, 0])
}
public func keyUp() {
clearReport()
}
// MARK: - Report descriptor
// Minimal boot-keyboard shell with Consumer page (0x0C) keycodes.
private static let reportDescriptor: [UInt8] = [
0x05, 0x0C, // Usage Page (Consumer)
0x09, 0x01, // Usage (Consumer Control)
0xA1, 0x01, // Collection (Application)
0x19, 0x00, // Usage Minimum (0)
0x29, 0xFF, // Usage Maximum (255)
0x15, 0x00, // Logical Minimum (0)
0x25, 0xFF, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x08, // Report Count (8)
0x81, 0x00, // Input (Data, Array)
0xC0, // End Collection
]
}
// A synthetic USB HID device that delivers arbitrary fixed-size HID reports.
// Build descriptors with SyntheticHID.Descriptors or supply your own.
import Foundation
// MARK: - SyntheticHID
/// A synthetic USB HID device backed by SyntheticIOUSBDevice.
/// Inject reports with `sendReport(_:)` / `sendReportAndRelease(_:)`.
open class SyntheticHID: SyntheticIOUSBDevice {
private var currentReportData: [UInt8]
private let emptyReport: [UInt8]
// MARK: Init
/// - Parameters:
/// - reportDescriptor: Raw HID report descriptor bytes.
/// - reportSize: Size (in bytes) of a single input report.
/// - vendorID: USB vendor ID (little-endian). Default: Apple (0x05AC).
/// - productID: USB product ID (little-endian). Default: 0x0001.
/// - manufacturer: Manufacturer string.
/// - product: Product string.
/// - interfaceSubClass: bInterfaceSubClass (0x01 = Boot Interface, 0x00 = none).
/// - interfaceProtocol: bInterfaceProtocol (0x01 = Keyboard, 0x02 = Mouse, 0x00 = none).
public init(reportDescriptor: [UInt8],
reportSize: Int,
vendorID: UInt16 = 0x05AC,
productID: UInt16 = 0x0001,
manufacturer: String = "Apple Inc.",
product: String = "Virtual HID Device",
interfaceSubClass: UInt8 = 0x01,
interfaceProtocol: UInt8 = 0x01) {
emptyReport = [UInt8](repeating: 0, count: reportSize)
currentReportData = emptyReport
let device = Self.makeDeviceDescriptor(vendorID: vendorID, productID: productID)
let config = Self.makeConfigDescriptor(hidReportLen: reportDescriptor.count,
subClass: interfaceSubClass,
protocol_: interfaceProtocol)
let descs = USBDeviceDescriptors(device: device, configuration: config,
hidReport: reportDescriptor,
manufacturer: manufacturer, product: product)
super.init(descriptors: descs)
}
// MARK: Report injection
/// Set the report that will be returned on the next interrupt IN poll.
public func sendReport(_ bytes: [UInt8]) {
currentReportData = bytes
}
/// Reset the current report to all zeros.
public func clearReport() {
currentReportData = emptyReport
}
// MARK: SyntheticIOUSBDevice overrides
open override func interruptINData(maxLength: Int) -> [UInt8] {
Array(currentReportData.prefix(maxLength))
}
open override func getReport(maxLength: Int) -> [UInt8] {
Array(emptyReport.prefix(maxLength))
}
// MARK: Descriptor builders
private static func makeDeviceDescriptor(vendorID: UInt16, productID: UInt16) -> [UInt8] {
[
18, // bLength
0x01, // bDescriptorType: DEVICE
0x00, 0x02, // bcdUSB: USB 2.0
0x00, // bDeviceClass: defined by interface
0x00, // bDeviceSubClass
0x00, // bDeviceProtocol
8, // bMaxPacketSize0
UInt8(vendorID & 0xFF), UInt8(vendorID >> 8),
UInt8(productID & 0xFF), UInt8(productID >> 8),
0x00, 0x01, // bcdDevice: 1.00
0x01, // iManufacturer
0x02, // iProduct
0x00, // iSerialNumber: none
0x01, // bNumConfigurations
]
}
/// Builds a standard single-interface HID configuration descriptor
/// (Config 9B + Interface 9B + HID 9B + EP 7B = 34B total).
private static func makeConfigDescriptor(hidReportLen: Int,
subClass: UInt8 = 0x01,
protocol_: UInt8 = 0x01) -> [UInt8] {
let lo = UInt8(hidReportLen & 0xFF)
let hi = UInt8((hidReportLen >> 8) & 0xFF)
return [
// Configuration descriptor (9 bytes)
9, 0x02, 34, 0, // bLength, bDescriptorType, wTotalLength LE
0x01, // bNumInterfaces
0x01, // bConfigurationValue
0x00, // iConfiguration
0xA0, // bmAttributes: bus powered + remote wakeup
50, // bMaxPower: 100 mA (2 mA units)
// Interface descriptor (9 bytes)
9, 0x04, // bLength, bDescriptorType: INTERFACE
0x00, 0x00, // bInterfaceNumber, bAlternateSetting
0x01, // bNumEndpoints
0x03, // bInterfaceClass: HID
subClass, // bInterfaceSubClass
protocol_, // bInterfaceProtocol
0x00, // iInterface
// HID descriptor (9 bytes)
9, 0x21, // bLength, bDescriptorType: HID
0x11, 0x01, // bcdHID: 1.11
0x00, // bCountryCode
0x01, // bNumDescriptors
0x22, // bDescriptorType[0]: Report
lo, hi, // wDescriptorLength[0]
// Endpoint descriptor (7 bytes) — EP1 IN, Interrupt
7, 0x05, // bLength, bDescriptorType: ENDPOINT
0x81, // bEndpointAddress: IN EP1
0x03, // bmAttributes: Interrupt
8, 0, // wMaxPacketSize: 8 bytes LE
10, // bInterval: 10 ms
]
}
}
// Generic synthetic USB device backed by IOUSBHostControllerInterface.
// Subclass and override `interruptINData(maxLength:)` and `getReport(maxLength:)`
// to deliver custom data to the host.
//
// Entitlement required: com.apple.developer.usb.host-controller-interface
import Foundation
import IOUSBHost
// MARK: - USB descriptor bundle
public struct USBDeviceDescriptors {
public let device: [UInt8]
public let configuration: [UInt8]
public let hidReport: [UInt8]
public let manufacturer: String
public let product: String
public init(
device: [UInt8], configuration: [UInt8], hidReport: [UInt8],
manufacturer: String, product: String
) {
self.device = device
self.configuration = configuration
self.hidReport = hidReport
self.manufacturer = manufacturer
self.product = product
}
}
// MARK: - USB / HID request constants
private let kUSBReqGetDescriptor: UInt8 = 0x06
private let kUSBReqGetConfig: UInt8 = 0x08
private let kUSBReqGetInterface: UInt8 = 0x0A
private let kUSBDescDevice: UInt8 = 0x01
private let kUSBDescConfig: UInt8 = 0x02
private let kUSBDescString: UInt8 = 0x03
private let kHIDReqGetDescriptor: UInt8 = 0x06
private let kHIDDescHID: UInt8 = 0x21
private let kHIDDescReport: UInt8 = 0x22
private let kMsgTypeMask: UInt32 = 0x3F
private let kMsgValid: UInt32 = (1 << 15)
// MARK: - SyntheticIOUSBDevice
/// A generic synthetic USB 2.0 full-speed device presented via IOUSBHostControllerInterface.
/// Descriptors are supplied at init time. Subclass to customise data delivery.
open class SyntheticIOUSBDevice: NSObject {
// MARK: Properties
public let descriptors: USBDeviceDescriptors
private var controller: IOUSBHostControllerInterface?
private var deviceSMs: [Int: IOUSBHostCIDeviceStateMachine] = [:]
private var endpointSMs: [Int: IOUSBHostCIEndpointStateMachine] = [:]
private var frameNumber: UInt64 = 0
private var portSM: IOUSBHostCIPortStateMachine?
private var deviceConnected = false
private var pendingResponse: Data?
private let string0: [UInt8] = [4, 0x03, 0x09, 0x04] // English (United States) language ID
private let stringManuf: [UInt8]
private let stringProd: [UInt8]
// MARK: Init
public init(descriptors: USBDeviceDescriptors) {
self.descriptors = descriptors
self.stringManuf = Self.makeStringDescriptor(descriptors.manufacturer)
self.stringProd = Self.makeStringDescriptor(descriptors.product)
super.init()
}
// MARK: Override points
/// Called each time the host polls the interrupt IN endpoint (EP 0x81).
/// Return the bytes to deliver; the result is clamped to `maxLength`.
open func interruptINData(maxLength: Int) -> [UInt8] { [] }
/// Called for a HID GET_REPORT class request (bmRequestType 0xA1).
open func getReport(maxLength: Int) -> [UInt8] { [] }
// MARK: Start / Stop
public func start() throws {
var err: NSError?
let ci = IOUSBHostControllerInterface(
__capabilities: buildCapabilities(),
queue: nil,
interruptRateHz: 0,
error: &err,
commandHandler: { [weak self] ci, cmd in self?.handleCommand(ci, cmd) },
doorbellHandler: { [weak self] ci, db, n in self?.handleDoorbells(ci, db, n) },
interestHandler: nil)
if let e = err, e.code != 0 { throw e }
guard let ci else {
throw NSError(
domain: "SyntheticIOUSBDevice", code: -1,
userInfo: [
NSLocalizedDescriptionKey: "Failed to create IOUSBHostControllerInterface"
])
}
controller = ci
print("[\(typeName)] Controller created — UUID: \(ci.uuid.uuidString)")
}
public func stop() {
controller?.destroy()
controller = nil
print("[\(typeName)] Stopped.")
}
private var typeName: String { String(describing: type(of: self)) }
// MARK: Capabilities
private func buildCapabilities() -> Data {
var ctlCap = IOUSBHostCIMessage()
ctlCap.control =
UInt32(IOUSBHostCIMessageTypeControllerCapabilities.rawValue)
| (1 << 14) // NoResponse
| (1 << 15) // Valid
| (1 << 16) // PortCount = 1
ctlCap.data0 = (1 << 0) | (2 << 4) // CommandTimeoutThreshold=2s, ConnectionLatency=4ms
var portCap = IOUSBHostCIMessage()
portCap.control =
UInt32(IOUSBHostCIMessageTypePortCapabilities.rawValue)
| (1 << 14) // NoResponse
| (1 << 15) // Valid
| (1 << 16) // PortNumber = 1
| (0 << 24) // ConnectorType = TypeA
portCap.data0 = UInt32(500 / 8) // MaxPower: 500 mA in 8 mA units
var data = Data(bytes: &ctlCap, count: MemoryLayout<IOUSBHostCIMessage>.size)
data.append(Data(bytes: &portCap, count: MemoryLayout<IOUSBHostCIMessage>.size))
return data
}
// MARK: Command handler
private func handleCommand(_ ci: IOUSBHostControllerInterface, _ cmdIn: IOUSBHostCIMessage) {
var cmd = cmdIn
let rawType = cmd.control & kMsgTypeMask
let msgType = IOUSBHostCIMessageType(rawValue: rawType)
let name =
IOUSBHostCIMessageTypeToString(msgType).flatMap { String(cString: $0) }
?? "0x\(String(format: "%02X", rawType))"
print("[\(typeName)] CMD \(name)")
do {
switch msgType {
// ── Controller ────────────────────────────────────────────────
case IOUSBHostCIMessageTypeControllerPowerOn,
IOUSBHostCIMessageTypeControllerPowerOff,
IOUSBHostCIMessageTypeControllerStart,
IOUSBHostCIMessageTypeControllerPause:
try ci.controllerStateMachine.respond(
toCommand: &cmd, status: IOUSBHostCIMessageStatusSuccess)
// ── Port ──────────────────────────────────────────────────────
case IOUSBHostCIMessageTypePortPowerOn,
IOUSBHostCIMessageTypePortPowerOff,
IOUSBHostCIMessageTypePortResume,
IOUSBHostCIMessageTypePortSuspend,
IOUSBHostCIMessageTypePortReset,
IOUSBHostCIMessageTypePortDisable,
IOUSBHostCIMessageTypePortStatus:
var portErr: NSError?
let psm = ci.getPortStateMachine(forCommand: &cmd, error: &portErr)
if portErr == nil || portErr!.code == 0 {
portSM = psm
try psm.respond(toCommand: &cmd, status: IOUSBHostCIMessageStatusSuccess)
if msgType == IOUSBHostCIMessageTypePortPowerOn {
psm.powered = true
if !deviceConnected {
deviceConnected = true
psm.connected = true
try psm.updateLinkState(
IOUSBHostCILinkStateU0,
speed: IOUSBHostCIDeviceSpeedFull,
inhibitLinkStateChange: false)
print("[\(typeName)] Port 1: device connected (full-speed)")
}
} else if msgType == IOUSBHostCIMessageTypePortReset {
try psm.updateLinkState(
IOUSBHostCILinkStateU0,
speed: IOUSBHostCIDeviceSpeedFull,
inhibitLinkStateChange: false)
}
} else if let e = portErr {
print("[\(typeName)] getPortStateMachine: \(e)")
}
// ── Device ────────────────────────────────────────────────────
case IOUSBHostCIMessageTypeDeviceCreate:
let dsm = try IOUSBHostCIDeviceStateMachine(__interface: ci, command: &cmd)
let addr = 1
try dsm.respond(
toCommand: &cmd, status: IOUSBHostCIMessageStatusSuccess, deviceAddress: addr)
deviceSMs[addr] = dsm
print("[\(typeName)] Device at address \(addr)")
case IOUSBHostCIMessageTypeDeviceDestroy,
IOUSBHostCIMessageTypeDeviceStart,
IOUSBHostCIMessageTypeDevicePause,
IOUSBHostCIMessageTypeDeviceUpdate:
let devAddr = Int(cmd.data0 & 0xFF)
if let dsm = deviceSMs[devAddr] {
try dsm.respond(toCommand: &cmd, status: IOUSBHostCIMessageStatusSuccess)
if msgType == IOUSBHostCIMessageTypeDeviceDestroy {
deviceSMs.removeValue(forKey: devAddr)
}
}
// ── Endpoint ──────────────────────────────────────────────────
case IOUSBHostCIMessageTypeEndpointCreate:
let esm = try IOUSBHostCIEndpointStateMachine(__interface: ci, command: &cmd)
try esm.respond(toCommand: &cmd, status: IOUSBHostCIMessageStatusSuccess)
let key = (esm.deviceAddress << 8) | esm.endpointAddress
endpointSMs[key] = esm
print(
"[\(typeName)] Endpoint device=\(esm.deviceAddress) ep=0x\(String(format: "%02X", esm.endpointAddress))"
)
case IOUSBHostCIMessageTypeEndpointDestroy,
IOUSBHostCIMessageTypeEndpointPause,
IOUSBHostCIMessageTypeEndpointUpdate,
IOUSBHostCIMessageTypeEndpointReset,
IOUSBHostCIMessageTypeEndpointSetNextTransfer:
let devAddr = Int(cmd.data0 & 0xFF)
let epAddr = Int((cmd.data0 >> 8) & 0xFF)
let key = (devAddr << 8) | epAddr
if let esm = endpointSMs[key] {
try esm.respond(toCommand: &cmd, status: IOUSBHostCIMessageStatusSuccess)
if msgType == IOUSBHostCIMessageTypeEndpointDestroy {
endpointSMs.removeValue(forKey: key)
}
}
default:
print("[\(typeName)] Unhandled 0x\(String(format: "%02X", rawType))")
}
} catch {
print("[\(typeName)] handleCommand error: \(error)")
}
}
// MARK: Doorbell handler
private func handleDoorbells(
_ ci: IOUSBHostControllerInterface,
_ doorbells: UnsafePointer<IOUSBHostCIDoorbell>,
_ count: UInt32
) {
for i in 0..<Int(count) {
let db = doorbells[i]
let devAddr = Int(db & 0xFF)
let epAddr = Int((db >> 8) & 0xFF)
let key = (devAddr << 8) | epAddr
guard let esm = endpointSMs[key] else { continue }
do {
try esm.processDoorbell(db)
try processTransfers(for: esm)
} catch {
print(
"[\(typeName)] Doorbell ep=0x\(String(format: "%02X", epAddr)) error: \(error)")
}
}
}
// MARK: Transfer processing
private func processTransfers(for esm: IOUSBHostCIEndpointStateMachine) throws {
while esm.endpointState == IOUSBHostCIEndpointStateActive {
let xfer = esm.currentTransferMessage
guard (xfer.pointee.control & kMsgValid) != 0 else { break }
switch IOUSBHostCIMessageType(rawValue: xfer.pointee.control & kMsgTypeMask) {
case IOUSBHostCIMessageTypeSetupTransfer:
handleSetupTransfer(esm: esm, xfer: xfer)
case IOUSBHostCIMessageTypeNormalTransfer:
try handleNormalTransfer(esm: esm, xfer: xfer)
case IOUSBHostCIMessageTypeStatusTransfer:
try esm.enqueueTransferCompletion(
for: xfer,
status: IOUSBHostCIMessageStatusSuccess,
transferLength: 0)
default:
return // non-data message — wait for next doorbell
}
}
}
private func handleSetupTransfer(
esm: IOUSBHostCIEndpointStateMachine,
xfer: UnsafePointer<IOUSBHostCIMessage>
) {
let d1 = xfer.pointee.data1
let bmRequestType = UInt8((d1 >> 0) & 0xFF)
let bRequest = UInt8((d1 >> 8) & 0xFF)
let wValue = UInt16((d1 >> 16) & 0xFFFF)
let wLength = UInt16((d1 >> 48) & 0xFFFF)
let descType = UInt8((wValue >> 8) & 0xFF)
let descIndex = UInt8(wValue & 0xFF)
print(
"[\(typeName)] SETUP bmRT=0x\(String(format: "%02X", bmRequestType)) bReq=0x\(String(format: "%02X", bRequest)) wVal=0x\(String(format: "%04X", wValue)) wLen=\(wLength)"
)
pendingResponse = resolveControlRequest(
bmRequestType: bmRequestType, bRequest: bRequest,
descType: descType, descIndex: descIndex, wLength: wLength)
do {
try esm.enqueueTransferCompletion(
for: xfer,
status: IOUSBHostCIMessageStatusSuccess,
transferLength: 0)
} catch {
print("[\(typeName)] Setup ACK error: \(error)")
}
}
private func handleNormalTransfer(
esm: IOUSBHostCIEndpointStateMachine,
xfer: UnsafePointer<IOUSBHostCIMessage>
) throws {
if esm.endpointAddress == 0x81 {
// Interrupt IN — deliver subclass data
let maxLen = Int(xfer.pointee.data0 & 0x0FFF_FFFF)
let bytes = interruptINData(maxLength: maxLen)
let n = min(bytes.count, maxLen)
if let buf = UnsafeMutableRawPointer(bitPattern: UInt(xfer.pointee.data1)) {
buf.copyMemory(from: bytes, byteCount: n)
}
try esm.enqueueTransferCompletion(
for: xfer,
status: IOUSBHostCIMessageStatusSuccess,
transferLength: n)
} else {
// EP0 data IN phase — fill from pending control response
let maxLen = Int(xfer.pointee.data0 & 0x0FFF_FFFF)
var written = 0
if let resp = pendingResponse, !resp.isEmpty,
let buf = UnsafeMutableRawPointer(bitPattern: UInt(xfer.pointee.data1))
{
let n = min(resp.count, maxLen)
resp.withUnsafeBytes { buf.copyMemory(from: $0.baseAddress!, byteCount: n) }
written = n
pendingResponse = nil
}
try esm.enqueueTransferCompletion(
for: xfer,
status: IOUSBHostCIMessageStatusSuccess,
transferLength: written)
}
}
// MARK: Control request dispatch
private func resolveControlRequest(
bmRequestType: UInt8, bRequest: UInt8,
descType: UInt8, descIndex: UInt8,
wLength: UInt16
) -> Data? {
switch bmRequestType {
case 0x80: // Standard Device → Host
switch bRequest {
case kUSBReqGetDescriptor:
switch descType {
case kUSBDescDevice: return prefix(descriptors.device, wLength)
case kUSBDescConfig: return prefix(descriptors.configuration, wLength)
case kUSBDescString:
switch descIndex {
case 0: return prefix(string0, wLength)
case 1: return prefix(stringManuf, wLength)
case 2: return prefix(stringProd, wLength)
default: return nil
}
default: return nil
}
case kUSBReqGetConfig: return Data([1])
default: return Data()
}
case 0x81: // Standard Interface → Host
switch bRequest {
case kUSBReqGetInterface: return Data([0])
case kHIDReqGetDescriptor:
switch descType {
case kHIDDescReport: return prefix(descriptors.hidReport, wLength)
case kHIDDescHID:
// HID descriptor sits at bytes [18 ... 26] of the config blob
// (after 9-byte config + 9-byte interface descriptors)
let start = 18
let end = 27
guard descriptors.configuration.count >= end else { return nil }
return prefix(Array(descriptors.configuration[start..<end]), wLength)
default: return nil
}
default: return nil
}
case 0x21: // Class Host → Device (SET_PROTOCOL, SET_IDLE, SET_REPORT)
return Data()
case 0xA1: // Class Device → Host (GET_REPORT)
return Data(getReport(maxLength: Int(wLength)))
default:
return Data()
}
}
// MARK: Helpers
private func prefix(_ bytes: [UInt8], _ max: UInt16) -> Data {
Data(bytes.prefix(Int(max)))
}
private static func makeStringDescriptor(_ text: String) -> [UInt8] {
let utf16 = Array(text.utf16)
var result: [UInt8] = [UInt8(2 + utf16.count * 2), 0x03]
for cp in utf16 {
result.append(UInt8(cp & 0xFF))
result.append(UInt8(cp >> 8))
}
return result
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment