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