|
| 1 | +// DeviceOwnership.swift |
| 2 | +// Determines whether anything already owns a USB device, before we promise to share it. |
| 3 | + |
| 4 | +import Foundation |
| 5 | +import IOKit |
| 6 | +import Common |
| 7 | + |
| 8 | +/// Who currently holds a device's interfaces. |
| 9 | +public enum DeviceOwnership: Equatable { |
| 10 | + /// Nothing has matched against the interfaces. This is the servable case. |
| 11 | + case unbound |
| 12 | + |
| 13 | + /// A kernel driver owns at least one interface. Releasing it needs DriverKit |
| 14 | + /// rebinding, which requires an entitlement Apple has to grant — measured on |
| 15 | + /// 2026-08-06, `USBInterfaceOpenSeize` does not help. |
| 16 | + case kernelDriver(drivers: [String]) |
| 17 | + |
| 18 | + /// A userspace process holds the interfaces open. Blocks a claim just as firmly, |
| 19 | + /// but quitting that process frees the device, so the two must not be reported |
| 20 | + /// alike — one is a dead end, the other is a thing the user can act on. |
| 21 | + case userspaceProcess(clients: [String]) |
| 22 | + |
| 23 | + public var isServable: Bool { |
| 24 | + if case .unbound = self { return true } |
| 25 | + return false |
| 26 | + } |
| 27 | +} |
| 28 | + |
| 29 | +/// Structural USB nodes. Their presence says nothing about whether a function driver |
| 30 | +/// claimed anything — every device has them. |
| 31 | +private let structuralClasses: Set<String> = [ |
| 32 | + "IOUSBHostDevice", |
| 33 | + "IOUSBDevice", |
| 34 | + "IOUSBHostInterface", |
| 35 | + "IOUSBInterface", |
| 36 | + "IOUSBHostLegacyClient", |
| 37 | + "AppleUSBHostLegacyClient", |
| 38 | + "IOUSBHostLegacyDevice", |
| 39 | + "IOUSBHostLegacyInterface", |
| 40 | + // Creates the interface nodes; structural, not a claim. |
| 41 | + "AppleUSBHostCompositeDevice", |
| 42 | + // A transient device-level handle that appears whenever any process opens the |
| 43 | + // device — including this project's own probe. It says nothing about whether an |
| 44 | + // interface can be claimed, and treating it as an owner made a device that opens |
| 45 | + // perfectly well report as taken. |
| 46 | + "AppleUSBHostDeviceUserClient", |
| 47 | + "IOUSBHostDeviceUserClient" |
| 48 | +] |
| 49 | + |
| 50 | +/// Userspace client connections — libusb, WebUSB, a framework daemon. Not kernel |
| 51 | +/// drivers. Counting them as such made a fully usable device look kernel-owned during |
| 52 | +/// harness development, and pointed at DriverKit when the fix was to quit an app. |
| 53 | +private let userspaceClientClasses: Set<String> = [ |
| 54 | + "AppleUSBHostFrameworkInterfaceClient", |
| 55 | + "IOUSBHostInterfaceUserClient", |
| 56 | + "AppleUSBHostFrameworkDeviceClient" |
| 57 | +] |
| 58 | + |
| 59 | +/// Reads device ownership out of the IORegistry. |
| 60 | +public struct DeviceOwnershipInspector { |
| 61 | + private let ioKit: IOKitInterface |
| 62 | + |
| 63 | + public init(ioKit: IOKitInterface = RealIOKitInterface()) { |
| 64 | + self.ioKit = ioKit |
| 65 | + } |
| 66 | + |
| 67 | + /// Determine who owns the device with this vendor and product ID. |
| 68 | + /// |
| 69 | + /// Returns `.unbound` when the device cannot be found: absent evidence of an |
| 70 | + /// owner, refusing to bind would be worse than letting the attempt proceed and |
| 71 | + /// fail with a real error. |
| 72 | + public func ownership(vendorID: UInt16, productID: UInt16) -> DeviceOwnership { |
| 73 | + guard let matching = ioKit.serviceMatching("IOUSBHostDevice") else { |
| 74 | + return .unbound |
| 75 | + } |
| 76 | + |
| 77 | + var iterator: io_iterator_t = 0 |
| 78 | + guard ioKit.serviceGetMatchingServices(kIOMasterPortDefault, matching, &iterator) == KERN_SUCCESS else { |
| 79 | + return .unbound |
| 80 | + } |
| 81 | + defer { _ = ioKit.objectRelease(iterator) } |
| 82 | + |
| 83 | + // Bind the current service to a `let` before the defer. A `defer` capturing the |
| 84 | + // loop's `var` releases whatever it holds when the iteration ends — by which |
| 85 | + // point it has already been reassigned to the next service, so each pass freed |
| 86 | + // the entry the next pass was about to read. |
| 87 | + while true { |
| 88 | + let service = ioKit.iteratorNext(iterator) |
| 89 | + guard service != 0 else { break } |
| 90 | + defer { _ = ioKit.objectRelease(service) } |
| 91 | + |
| 92 | + if intProperty(service, "idVendor") == Int(vendorID), |
| 93 | + intProperty(service, "idProduct") == Int(productID) { |
| 94 | + return classify(collectClaimants(of: service)) |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + return .unbound |
| 99 | + } |
| 100 | + |
| 101 | + /// Drivers matched against the device's interfaces. |
| 102 | + /// |
| 103 | + /// Claimability is decided per interface, not for the device as a whole: a device |
| 104 | + /// can be opened while its interface is held by someone else, and it is the |
| 105 | + /// interface that a transfer needs. Collecting every class in the subtree instead |
| 106 | + /// swept up unrelated nodes and reported servable hardware as owned. |
| 107 | + private func collectClaimants(of deviceEntry: io_registry_entry_t) -> [String] { |
| 108 | + var claimants: [String] = [] |
| 109 | + for interfaceEntry in interfaceNodes(under: deviceEntry) { |
| 110 | + defer { _ = ioKit.objectRelease(interfaceEntry) } |
| 111 | + claimants.append(contentsOf: immediateDrivers(of: interfaceEntry)) |
| 112 | + } |
| 113 | + return claimants |
| 114 | + } |
| 115 | + |
| 116 | + /// Interface nodes below a device, wherever the composite driver put them. |
| 117 | + private func interfaceNodes(under entry: io_registry_entry_t, depth: Int = 0) -> [io_registry_entry_t] { |
| 118 | + guard depth < 4 else { return [] } |
| 119 | + |
| 120 | + var iterator: io_iterator_t = 0 |
| 121 | + guard ioKit.registryEntryGetChildIterator(entry, kIOServicePlane, &iterator) == KERN_SUCCESS else { |
| 122 | + return [] |
| 123 | + } |
| 124 | + defer { _ = ioKit.objectRelease(iterator) } |
| 125 | + |
| 126 | + var interfaces: [io_registry_entry_t] = [] |
| 127 | + while true { |
| 128 | + let child = ioKit.iteratorNext(iterator) |
| 129 | + guard child != 0 else { break } |
| 130 | + |
| 131 | + let className = ioKit.objectCopyClass(child) ?? "" |
| 132 | + if className == "IOUSBHostInterface" || className == "IOUSBInterface" { |
| 133 | + interfaces.append(child) // released by the caller |
| 134 | + } else { |
| 135 | + interfaces.append(contentsOf: interfaceNodes(under: child, depth: depth + 1)) |
| 136 | + _ = ioKit.objectRelease(child) |
| 137 | + } |
| 138 | + } |
| 139 | + return interfaces |
| 140 | + } |
| 141 | + |
| 142 | + /// Immediate driver children of one interface — what actually holds it. |
| 143 | + private func immediateDrivers(of interfaceEntry: io_registry_entry_t) -> [String] { |
| 144 | + var iterator: io_iterator_t = 0 |
| 145 | + guard ioKit.registryEntryGetChildIterator(interfaceEntry, kIOServicePlane, &iterator) == KERN_SUCCESS else { |
| 146 | + return [] |
| 147 | + } |
| 148 | + defer { _ = ioKit.objectRelease(iterator) } |
| 149 | + |
| 150 | + var drivers: [String] = [] |
| 151 | + while true { |
| 152 | + let child = ioKit.iteratorNext(iterator) |
| 153 | + guard child != 0 else { break } |
| 154 | + defer { _ = ioKit.objectRelease(child) } |
| 155 | + |
| 156 | + if let className = ioKit.objectCopyClass(child), !structuralClasses.contains(className) { |
| 157 | + drivers.append(className) |
| 158 | + } |
| 159 | + } |
| 160 | + return drivers |
| 161 | + } |
| 162 | + |
| 163 | + private func classify(_ claimants: [String]) -> DeviceOwnership { |
| 164 | + guard !claimants.isEmpty else { return .unbound } |
| 165 | + |
| 166 | + let unique = Array(Set(claimants)).sorted() |
| 167 | + let kernelDrivers = unique.filter { !userspaceClientClasses.contains($0) } |
| 168 | + |
| 169 | + // A device can have both — a webcam's audio control interface is kernel-owned |
| 170 | + // while its video interfaces are held by the camera framework. A kernel driver |
| 171 | + // is the harder blocker, so it decides the verdict. |
| 172 | + if kernelDrivers.isEmpty { |
| 173 | + return .userspaceProcess(clients: unique) |
| 174 | + } |
| 175 | + return .kernelDriver(drivers: kernelDrivers) |
| 176 | + } |
| 177 | + |
| 178 | + private func intProperty(_ service: io_service_t, _ key: String) -> Int? { |
| 179 | + guard let ref = ioKit.registryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0) else { |
| 180 | + return nil |
| 181 | + } |
| 182 | + return (ref.takeRetainedValue() as? NSNumber)?.intValue |
| 183 | + } |
| 184 | +} |
0 commit comments