From b4a8901a4418f7a77a75ba898eae22f4c01f3083 Mon Sep 17 00:00:00 2001 From: Yu-Xi Lim Date: Sun, 23 Aug 2026 15:37:55 +0800 Subject: [PATCH] Re-resolve the NanoKVM USB stick when it moves to another port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device.videoDeviceUniqueID and serialDevicePath both persist a USB location rather than device identity: a UVC uniqueID embeds the locationID it enumerated at, and /dev/cu.usbserial-NNNN encodes the same thing. Replug the stick into a different port and both saved values dangle — AVCaptureDevice(uniqueID:) returns nil, open() returns ENOENT — so connect() fails with "may have been unplugged" while the hardware sits there working. Resolve both against what is actually attached before opening anything. The camera falls back to the single attached device sharing the saved uniqueID's VID/PID tail. The serial bridge then falls back to the port hanging off that camera's parent hub: on a NanoKVM-USB the capture chip and the CH340 are two functions behind the stick's own hub, so the pairing is exact rather than a guess. Ambiguity — two identical sticks — still fails, now saying to re-select in Edit Device. USBLocationID carries the locationID arithmetic as pure functions so it is testable without hardware; USBSerialPort now reports the locationID that the IOKit parent walk was already positioned to read. Also correct CLAUDE.md: the macOS app is not sandboxed. The App Sandbox was dropped to get raw /dev/cu.* access, and device.camera is the only entitlement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013CARgLMkYsjjJYFysqzWih --- CLAUDE.md | 2 +- .../NanoKVMUSB/CH9329SerialTransport.swift | 4 + .../NanoKVMUSB/NanoKVMUSBSession.swift | 19 +++- .../NanoKVMUSB/USBKVMDeviceDiscovery.swift | 87 ++++++++++++++++--- .../KVMCore/NanoKVMUSB/USBLocationID.swift | 58 +++++++++++++ .../KVMCore/NanoKVMUSB/UVCCaptureSource.swift | 4 +- .../KVMCoreTests/USBLocationIDTests.swift | 58 +++++++++++++ 7 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 KVMCore/Sources/KVMCore/NanoKVMUSB/USBLocationID.swift create mode 100644 KVMCore/Tests/KVMCoreTests/USBLocationIDTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 3ff7b25..41f8796 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ Release pipeline is documented in `DeveloperRelease.md`. One published GitHub Re - Swift 6.0, `SWIFT_STRICT_CONCURRENCY: complete` - macOS 15 / iPadOS 26 deployment target - iPadOS app: `TARGETED_DEVICE_FAMILY=2` (iPad only — no iPhone, no Catalyst) -- macOS app: hardened runtime, app sandbox, `network.client` entitlement +- macOS app: hardened runtime, **not** sandboxed (the App Sandbox was dropped to get raw `/dev/cu.*` access for NanoKVM USB); the only entitlement is `device.camera` - No external Swift packages — only Apple frameworks (SwiftUI, AppKit/UIKit, AVFoundation, VideoToolbox, CoreMedia, CoreVideo, Security) ## Layout diff --git a/KVMCore/Sources/KVMCore/NanoKVMUSB/CH9329SerialTransport.swift b/KVMCore/Sources/KVMCore/NanoKVMUSB/CH9329SerialTransport.swift index 9cf4701..e231a83 100644 --- a/KVMCore/Sources/KVMCore/NanoKVMUSB/CH9329SerialTransport.swift +++ b/KVMCore/Sources/KVMCore/NanoKVMUSB/CH9329SerialTransport.swift @@ -3,6 +3,7 @@ import Darwin import Foundation public enum CH9329SerialError: Error, LocalizedError { + case portNotFound(path: String) case openFailed(path: String, errno: Int32) case configureFailed(errno: Int32) case writeFailed(errno: Int32) @@ -10,6 +11,9 @@ public enum CH9329SerialError: Error, LocalizedError { public var errorDescription: String? { switch self { + case .portNotFound(let path): + return "Serial port \(path) is no longer attached. If you moved the NanoKVM USB " + + "to another USB port, re-select it in Edit Device." case .openFailed(let path, let code): return "Could not open serial port \(path) (errno=\(code), \(String(cString: strerror(code))))" case .configureFailed(let code): diff --git a/KVMCore/Sources/KVMCore/NanoKVMUSB/NanoKVMUSBSession.swift b/KVMCore/Sources/KVMCore/NanoKVMUSB/NanoKVMUSBSession.swift index 1d6b589..7a70ba4 100644 --- a/KVMCore/Sources/KVMCore/NanoKVMUSB/NanoKVMUSBSession.swift +++ b/KVMCore/Sources/KVMCore/NanoKVMUSB/NanoKVMUSBSession.swift @@ -57,15 +57,30 @@ public final class NanoKVMUSBSession: KVMSession { let myGeneration = generation state = .connecting - guard let videoID = configuration.device.videoDeviceUniqueID, !videoID.isEmpty else { + guard let savedVideoID = configuration.device.videoDeviceUniqueID, !savedVideoID.isEmpty else { finishWithError(NanoKVMUSBError.missingVideoDevice) return } - guard let serialPath = configuration.device.serialDevicePath, !serialPath.isEmpty else { + guard let savedSerialPath = configuration.device.serialDevicePath, !savedSerialPath.isEmpty else { finishWithError(NanoKVMUSBError.missingSerialDevice) return } + // Both saved identifiers encode the USB port the stick was plugged into when it + // was picked, so moving it to another port invalidates them. Re-resolve against + // what's attached now before opening anything. + guard let videoID = USBKVMDeviceDiscovery.resolveVideoUniqueID(saved: savedVideoID) else { + finishWithError(UVCCaptureError.deviceNotFound(uniqueID: savedVideoID)) + return + } + guard let serialPath = USBKVMDeviceDiscovery.resolveSerialPath( + saved: savedSerialPath, + videoUniqueID: videoID + ) else { + finishWithError(CH9329SerialError.portNotFound(path: savedSerialPath)) + return + } + let capture = UVCCaptureSource(renderCoordinator: renderCoordinator) capture.onVideoSize = { [weak self] size in guard let self, self.generation == myGeneration else { return } diff --git a/KVMCore/Sources/KVMCore/NanoKVMUSB/USBKVMDeviceDiscovery.swift b/KVMCore/Sources/KVMCore/NanoKVMUSB/USBKVMDeviceDiscovery.swift index 4aa2151..ec83377 100644 --- a/KVMCore/Sources/KVMCore/NanoKVMUSB/USBKVMDeviceDiscovery.swift +++ b/KVMCore/Sources/KVMCore/NanoKVMUSB/USBKVMDeviceDiscovery.swift @@ -7,6 +7,15 @@ import IOKit.serial public struct USBSerialPort: Hashable, Sendable { public let path: String public let displayName: String + /// USB `locationID` of the device behind this port, used to pair it with the capture + /// chip on the same stick after a replug. `nil` when the IOKit walk can't find one. + public let locationID: UInt32? + + public init(path: String, displayName: String, locationID: UInt32? = nil) { + self.path = path + self.displayName = displayName + self.locationID = locationID + } } @MainActor @@ -41,10 +50,12 @@ public enum USBKVMDeviceDiscovery { defer { IOObjectRelease(service) } guard let path = stringProperty(service, key: kIOCalloutDeviceKey) else { continue } guard isLikelyUSBSerial(path: path) else { continue } - let productName = usbProductName(for: service) - let displayName = productName.map { "\($0) (\((path as NSString).lastPathComponent))" } + let usb = usbAttributes(for: service) + let displayName = usb.productName.map { "\($0) (\((path as NSString).lastPathComponent))" } ?? (path as NSString).lastPathComponent - ports.append(USBSerialPort(path: path, displayName: displayName)) + ports.append( + USBSerialPort(path: path, displayName: displayName, locationID: usb.locationID) + ) } return ports.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } @@ -68,25 +79,81 @@ public enum USBKVMDeviceDiscovery { return raw.takeRetainedValue() as? String } - /// Walks up the IOKit parent chain until we hit a USB device node, then returns - /// its product/vendor name if available. - private static func usbProductName(for service: io_object_t) -> String? { + /// Walks up the IOKit parent chain until we hit a USB device node, then returns its + /// product/vendor name and `locationID`. The name can appear a tier below the node + /// carrying the locationID, so the walk keeps going until it has both or runs out. + private static func usbAttributes( + for service: io_object_t + ) -> (productName: String?, locationID: UInt32?) { var current: io_registry_entry_t = service IOObjectRetain(current) defer { IOObjectRelease(current) } + var productName: String? + var locationID: UInt32? + for _ in 0..<8 { - if let name = stringProperty(current, key: "USB Product Name") { return name } - if let name = stringProperty(current, key: "USB Vendor Name") { return name } + if productName == nil { + productName = stringProperty(current, key: "USB Product Name") + ?? stringProperty(current, key: "USB Vendor Name") + } + if locationID == nil { + locationID = numberProperty(current, key: "locationID") + } + if productName != nil, locationID != nil { break } var parent: io_registry_entry_t = 0 guard IORegistryEntryGetParentEntry(current, kIOServicePlane, &parent) == KERN_SUCCESS else { - return nil + break } IOObjectRelease(current) current = parent } - return nil + return (productName, locationID) + } + + private static func numberProperty(_ service: io_object_t, key: String) -> UInt32? { + guard let raw = IORegistryEntryCreateCFProperty( + service, + key as CFString, + kCFAllocatorDefault, + 0 + ) else { return nil } + return (raw.takeRetainedValue() as? NSNumber)?.uint32Value + } + + /// Re-resolves a saved camera selection. `AVCaptureDevice.uniqueID` embeds the USB + /// port the stick was in when it was picked, so an exact hit is only the happy path; + /// otherwise fall back to the one attached camera with the same vendor/product. + public static func resolveVideoUniqueID(saved: String) -> String? { + if AVCaptureDevice(uniqueID: saved) != nil { return saved } + + guard let tail = USBLocationID.vendorProductTail(ofVideoUniqueID: saved) else { return nil } + let matches = videoDevices().filter { + USBLocationID.vendorProductTail(ofVideoUniqueID: $0.uniqueID) == tail + } + // Two identical sticks attached: nothing distinguishes them, so make the user pick. + guard matches.count == 1 else { return nil } + return matches[0].uniqueID + } + + /// Re-resolves a saved serial selection. `/dev/cu.usbserial-NNNN` names encode the USB + /// port too, so when the saved node is gone, pair the serial bridge to the already + /// resolved capture chip by finding the port that hangs off the same hub — on a + /// NanoKVM-USB the two are functions of one hub, whichever Mac port it lands in. + public static func resolveSerialPath(saved: String, videoUniqueID: String) -> String? { + let ports = serialPorts() + if ports.contains(where: { $0.path == saved }) { return saved } + + guard let cameraLocation = USBLocationID.locationID(ofVideoUniqueID: videoUniqueID) else { + return nil + } + let siblings = ports.filter { port in + guard let location = port.locationID else { return false } + return USBLocationID.areSiblings(cameraLocation, location) + } + guard siblings.count == 1 else { return nil } + return siblings[0].path } } #endif diff --git a/KVMCore/Sources/KVMCore/NanoKVMUSB/USBLocationID.swift b/KVMCore/Sources/KVMCore/NanoKVMUSB/USBLocationID.swift new file mode 100644 index 0000000..0586c65 --- /dev/null +++ b/KVMCore/Sources/KVMCore/NanoKVMUSB/USBLocationID.swift @@ -0,0 +1,58 @@ +#if os(macOS) +import Foundation + +/// Pure arithmetic over Apple's USB `locationID` and the UVC `uniqueID` strings built +/// from it. Both `AVCaptureDevice.uniqueID` and a `/dev/cu.usbserial-NNNN` name encode +/// *where* a device is plugged in, so they change the moment it moves to another port. +/// These helpers separate the part that identifies the hardware (vendor/product) and the +/// part that describes the topology (which hub it hangs off), so a saved selection can be +/// re-resolved after a replug. +enum USBLocationID { + /// The trailing `VID`+`PID` of a UVC `uniqueID` — the only portion that survives a + /// move to a different USB port. Returns `nil` for IDs that aren't USB-shaped + /// (Continuity cameras report a UUID instead). + static func vendorProductTail(ofVideoUniqueID id: String) -> String? { + guard let hex = usbHex(ofVideoUniqueID: id) else { return nil } + return String(hex.suffix(8)) + } + + /// The `locationID` prefix of a UVC `uniqueID`. + static func locationID(ofVideoUniqueID id: String) -> UInt32? { + guard let hex = usbHex(ofVideoUniqueID: id) else { return nil } + return UInt32(hex.dropLast(8), radix: 16) + } + + /// The `locationID` of the hub a device hangs off. + /// + /// A locationID is a nibble-per-tier path: the top byte is the controller and each + /// following nibble is a port number, zero-padded on the right. Clearing the lowest + /// non-zero port nibble therefore walks up exactly one tier. A device plugged straight + /// into the Mac has no hub above it and reports itself, so callers can tell that no + /// sibling relationship is derivable. + static func parentLocationID(_ location: UInt32) -> UInt32 { + // Only the low six nibbles are the port path; the top byte is the controller, and + // devices on different controllers must never come out as siblings. + for shift in stride(from: UInt32(0), through: UInt32(20), by: 4) + where (location >> shift) & 0xF != 0 { + return location & ~(UInt32(0xF) << shift) + } + return location + } + + /// True when two USB devices hang off the same hub — i.e. are two functions of one + /// composite gadget such as the NanoKVM-USB stick. + static func areSiblings(_ lhs: UInt32, _ rhs: UInt32) -> Bool { + let parent = parentLocationID(lhs) + guard parent != lhs else { return false } + return parent == parentLocationID(rhs) + } + + private static func usbHex(ofVideoUniqueID id: String) -> String? { + guard id.hasPrefix("0x") else { return nil } + let hex = id.dropFirst(2) + // locationID + VID(4) + PID(4): at least one digit of location must remain. + guard hex.count > 8, hex.allSatisfy(\.isHexDigit) else { return nil } + return String(hex) + } +} +#endif diff --git a/KVMCore/Sources/KVMCore/NanoKVMUSB/UVCCaptureSource.swift b/KVMCore/Sources/KVMCore/NanoKVMUSB/UVCCaptureSource.swift index 246ac9b..1ebf215 100644 --- a/KVMCore/Sources/KVMCore/NanoKVMUSB/UVCCaptureSource.swift +++ b/KVMCore/Sources/KVMCore/NanoKVMUSB/UVCCaptureSource.swift @@ -16,7 +16,9 @@ public enum UVCCaptureError: Error, LocalizedError { case .cameraAccessDenied: return "Camera access is denied. Grant KVM Console camera access in System Settings → Privacy & Security → Camera, then reconnect." case .deviceNotFound(let id): - return "Could not find USB video capture device (uniqueID=\(id)). It may have been unplugged." + return "Could not find USB video capture device (uniqueID=\(id)). It may have been " + + "unplugged, or more than one identical capture stick is attached — " + + "re-select it in Edit Device." case .cannotAddInput: return "AVCaptureSession refused the USB video device as an input." case .cannotAddOutput: diff --git a/KVMCore/Tests/KVMCoreTests/USBLocationIDTests.swift b/KVMCore/Tests/KVMCoreTests/USBLocationIDTests.swift new file mode 100644 index 0000000..75942fd --- /dev/null +++ b/KVMCore/Tests/KVMCoreTests/USBLocationIDTests.swift @@ -0,0 +1,58 @@ +#if os(macOS) +import XCTest +@testable import KVMCore + +final class USBLocationIDTests: XCTestCase { + // A UVC uniqueID is "0x" + locationID + VID(4) + PID(4). Only the tail identifies + // the hardware; the locationID changes whenever the stick moves to another port. + func testSplitsUVCUniqueIDIntoLocationAndVendorProduct() { + XCTAssertEqual(USBLocationID.vendorProductTail(ofVideoUniqueID: "0x2120000345f2131"), "345f2131") + XCTAssertEqual(USBLocationID.locationID(ofVideoUniqueID: "0x2120000345f2131"), 0x0212_0000) + } + + func testSameStickInADifferentPortKeepsItsVendorProductTail() { + XCTAssertEqual( + USBLocationID.vendorProductTail(ofVideoUniqueID: "0x1120000345f2131"), + USBLocationID.vendorProductTail(ofVideoUniqueID: "0x2120000345f2131") + ) + XCTAssertNotEqual( + USBLocationID.locationID(ofVideoUniqueID: "0x1120000345f2131"), + USBLocationID.locationID(ofVideoUniqueID: "0x2120000345f2131") + ) + } + + func testRejectsNonUSBUniqueIDs() { + // Continuity cameras report a UUID, not a locationID+VID/PID string. + XCTAssertNil(USBLocationID.vendorProductTail(ofVideoUniqueID: "47009D72-9914-4C70-B4D2-D6ED00000001")) + XCTAssertNil(USBLocationID.locationID(ofVideoUniqueID: "47009D72-9914-4C70-B4D2-D6ED00000001")) + XCTAssertNil(USBLocationID.vendorProductTail(ofVideoUniqueID: "0x345f2131")) + XCTAssertNil(USBLocationID.locationID(ofVideoUniqueID: "")) + } + + // The NanoKVM-USB is a hub with the capture chip and the CH340 behind it, so the two + // halves of one stick share a parent hub no matter which Mac port it lands in. + func testCaptureChipAndSerialBridgeOnOneStickShareAParentHub() { + let camera = USBLocationID.parentLocationID(0x0212_0000) + let serial = USBLocationID.parentLocationID(0x0214_0000) + XCTAssertEqual(camera, 0x0210_0000) + XCTAssertEqual(serial, 0x0210_0000) + } + + func testParentOfAHubIsItsOwnParentPort() { + XCTAssertEqual(USBLocationID.parentLocationID(0x0210_0000), 0x0200_0000) + } + + func testDevicesOnDifferentControllersAreNeverSiblings() { + XCTAssertNotEqual( + USBLocationID.parentLocationID(0x0212_0000), + USBLocationID.parentLocationID(0x0112_0000) + ) + } + + // A device plugged straight into the Mac has no hub parent; reporting itself signals + // "no sibling relationship can be derived" so callers don't pair unrelated devices. + func testRootDeviceReportsItselfAsItsOwnParent() { + XCTAssertEqual(USBLocationID.parentLocationID(0x0200_0000), 0x0200_0000) + } +} +#endif