diff --git a/launcher/AV-Live-Body/Sources/AVLiveBody/USBClient.swift b/launcher/AV-Live-Body/Sources/AVLiveBody/USBClient.swift new file mode 100644 index 0000000..e85ab63 --- /dev/null +++ b/launcher/AV-Live-Body/Sources/AVLiveBody/USBClient.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Transport abstraction over the usbmuxd Unix socket. The real +/// implementation wraps a `socket(AF_UNIX)`; tests inject a mock. +protocol MuxTransport { + func send(_ data: Data) + func receivePacket() -> Data? + func close() +} + +/// usbmux client: device discovery + connect-to-port. After a +/// successful `connect`, the same transport carries the raw tunneled +/// byte stream from the device. +final class USBClient { + private let transport: MuxTransport + private var tag: UInt32 = 0 + + init(transport: MuxTransport) { + self.transport = transport + } + + func listDevices() -> [Int] { + tag += 1 + transport.send(USBMuxProtocol.encode( + plist: ["MessageType": "ListDevices"], tag: tag)) + guard let reply = transport.receivePacket(), + let plist = USBMuxProtocol.decode(reply), + let list = plist["DeviceList"] as? [[String: Any]] + else { return [] } + return list.compactMap { $0["DeviceID"] as? Int } + } + + /// Returns true once the transport is tunneled to `port` on the + /// device. usbmux wants the TCP port in big-endian order. + func connect(deviceID: Int, port: UInt16) -> Bool { + tag += 1 + let swapped = Int((port << 8) | (port >> 8)) + transport.send(USBMuxProtocol.encode(plist: [ + "MessageType": "Connect", + "DeviceID": deviceID, + "PortNumber": swapped, + ], tag: tag)) + guard let reply = transport.receivePacket(), + let plist = USBMuxProtocol.decode(reply), + let number = plist["Number"] as? Int + else { return false } + return number == 0 + } +} diff --git a/launcher/AV-Live-Body/Sources/AVLiveBody/USBMuxProtocol.swift b/launcher/AV-Live-Body/Sources/AVLiveBody/USBMuxProtocol.swift index 15ef05a..40c17f6 100644 --- a/launcher/AV-Live-Body/Sources/AVLiveBody/USBMuxProtocol.swift +++ b/launcher/AV-Live-Body/Sources/AVLiveBody/USBMuxProtocol.swift @@ -28,7 +28,8 @@ enum USBMuxProtocol { for i in 0..<4 { d.append(UInt8((v >> (8 * i)) & 0xFF)) } } - static func readLE32(_ d: Data, _ offset: Int) -> UInt32 { + static func readLE32(_ d: Data, _ offset: Int) -> UInt32? { + guard offset >= 0, d.count >= offset + 4 else { return nil } let b = [UInt8](d) var v: UInt32 = 0 for i in 0..<4 { v |= UInt32(b[offset + i]) << (8 * i) } diff --git a/launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBClientTests.swift b/launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBClientTests.swift new file mode 100644 index 0000000..c663931 --- /dev/null +++ b/launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBClientTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import AVLiveBody + +/// In-memory stand-in for the usbmuxd Unix socket. +final class MockMuxTransport: MuxTransport { + var sent: [Data] = [] + var canned: [Data] = [] + func send(_ data: Data) { sent.append(data) } + func receivePacket() -> Data? { + canned.isEmpty ? nil : canned.removeFirst() + } + func close() {} +} + +final class USBClientTests: XCTestCase { + func testListDevicesParsesDeviceIDs() { + let mock = MockMuxTransport() + mock.canned = [USBMuxProtocol.encode(plist: [ + "DeviceList": [ + ["DeviceID": 42, + "Properties": ["ConnectionType": "USB"]], + ]], tag: 0)] + let client = USBClient(transport: mock) + let devices = client.listDevices() + XCTAssertEqual(devices, [42]) + } + + func testConnectSendsConnectRequest() { + let mock = MockMuxTransport() + mock.canned = [USBMuxProtocol.encode( + plist: ["MessageType": "Result", "Number": 0], tag: 0)] + let client = USBClient(transport: mock) + let ok = client.connect(deviceID: 42, port: 7000) + XCTAssertTrue(ok) + let req = USBMuxProtocol.decode(mock.sent.last!) + XCTAssertEqual(req?["MessageType"] as? String, "Connect") + XCTAssertEqual(req?["DeviceID"] as? Int, 42) + XCTAssertEqual(req?["PortNumber"] as? Int, + Int((UInt16(7000) << 8) | (UInt16(7000) >> 8))) + } + + func testConnectFailsOnNonZeroResult() { + let mock = MockMuxTransport() + mock.canned = [USBMuxProtocol.encode( + plist: ["MessageType": "Result", "Number": 3], tag: 0)] + let client = USBClient(transport: mock) + XCTAssertFalse(client.connect(deviceID: 1, port: 7000)) + } +} diff --git a/launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBMuxProtocolTests.swift b/launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBMuxProtocolTests.swift index 53fc351..7fbf2c9 100644 --- a/launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBMuxProtocolTests.swift +++ b/launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBMuxProtocolTests.swift @@ -6,7 +6,7 @@ final class USBMuxProtocolTests: XCTestCase { let body: [String: Any] = ["MessageType": "ListDevices"] let packet = USBMuxProtocol.encode(plist: body, tag: 3) XCTAssertGreaterThan(packet.count, 16) - XCTAssertEqual(Int(USBMuxProtocol.readLE32(packet, 0)), + XCTAssertEqual(USBMuxProtocol.readLE32(packet, 0).map(Int.init), packet.count) XCTAssertEqual(USBMuxProtocol.readLE32(packet, 4), 1) XCTAssertEqual(USBMuxProtocol.readLE32(packet, 8), 8)