Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ 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)
case closed

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):
Expand Down
19 changes: 17 additions & 2 deletions KVMCore/Sources/KVMCore/NanoKVMUSB/NanoKVMUSBSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
87 changes: 77 additions & 10 deletions KVMCore/Sources/KVMCore/NanoKVMUSB/USBKVMDeviceDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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
58 changes: 58 additions & 0 deletions KVMCore/Sources/KVMCore/NanoKVMUSB/USBLocationID.swift
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion KVMCore/Sources/KVMCore/NanoKVMUSB/UVCCaptureSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
58 changes: 58 additions & 0 deletions KVMCore/Tests/KVMCoreTests/USBLocationIDTests.swift
Original file line number Diff line number Diff line change
@@ -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
Loading