feat(avlivebody-mac): migrate usb transport

Context: the new native AVLiveBody app needs the proven iPhone-Mac
usbmux transport layer. These files are self-contained, depending
only on AVLiveWire plus Apple system frameworks, so they cross the
rewrite boundary unchanged.

Approach: copy the three transport files and their unit tests
byte-for-byte from launcher/AV-Live-Body, then make the test target
buildable.

Changes:
- Add usb/USBMuxProtocol.swift, usb/USBClient.swift and
  usb/VideoDecoder.swift under Sources/AVLiveBody.
- Add USBMuxProtocolTests.swift and USBClientTests.swift under
  Tests/AVLiveBodyTests.
- Set GENERATE_INFOPLIST_FILE=YES on the AVLiveBodyTests target so
  xcodebuild can code sign the now-populated test bundle.

Impact: the usbmux pipeline is available in the rewrite and its
six unit tests run green under xcodebuild test.
This commit is contained in:
L'électron rare
2026-05-18 19:15:54 +02:00
parent 950cb29628
commit db33e74c15
6 changed files with 436 additions and 0 deletions
@@ -0,0 +1,135 @@
import Foundation
import Darwin
/// 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
}
}
/// Production transport: blocking AF_UNIX socket to usbmuxd.
final class UnixMuxTransport: MuxTransport {
private var fd: Int32 = -1
init?(path: String = "/var/run/usbmuxd") {
fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
precondition(path.utf8.count < 104,
"usbmuxd socket path exceeds sun_path limit")
_ = path.withCString { src in
withUnsafeMutablePointer(to: &addr.sun_path) {
$0.withMemoryRebound(to: CChar.self, capacity: 104) {
strcpy($0, src)
}
}
}
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
let ok = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.connect(fd, $0, size)
}
}
if ok != 0 { Darwin.close(fd); return nil }
}
func send(_ data: Data) {
guard fd >= 0 else { return }
data.withUnsafeBytes { buf in
guard let base = buf.baseAddress else { return }
var off = 0
while off < data.count {
let w = Darwin.write(fd, base.advanced(by: off),
data.count - off)
if w <= 0 {
if w < 0 && errno == EINTR { continue }
break
}
off += w
}
}
}
/// Read one usbmux packet: 4-byte LE length prefix then body.
func receivePacket() -> Data? {
guard let head = readN(4) else { return nil }
guard let len = USBMuxProtocol.readLE32(head, 0) else { return nil }
let total = Int(len)
guard total >= 16, let rest = readN(total - 4) else { return nil }
return head + rest
}
/// Read raw tunneled bytes after a successful Connect.
func readStream(max: Int = 65536) -> Data? {
readN(max, exact: false)
}
private func readN(_ n: Int, exact: Bool = true) -> Data? {
var buf = [UInt8](repeating: 0, count: n)
var got = 0
while got < n {
let r = buf.withUnsafeMutableBytes {
Darwin.read(fd, $0.baseAddress!.advanced(by: got), n - got)
}
if r < 0 {
if errno == EINTR { continue }
return got > 0 && !exact ? Data(buf[0..<got]) : nil
}
if r == 0 { // EOF peer closed
return got > 0 && !exact ? Data(buf[0..<got]) : nil
}
got += r
if !exact { break }
}
return Data(buf[0..<got])
}
deinit { close() }
func close() {
if fd >= 0 { Darwin.close(fd); fd = -1 }
}
}
@@ -0,0 +1,38 @@
import Foundation
/// Codec for the usbmuxd request/response protocol. 16-byte
/// little-endian header (length, version=1, message=8, tag) then an
/// XML property list.
enum USBMuxProtocol {
static func encode(plist: [String: Any], tag: UInt32) -> Data {
let body = (try? PropertyListSerialization.data(
fromPropertyList: plist, format: .xml, options: 0))
?? Data()
var d = Data()
appendLE32(&d, UInt32(16 + body.count)) // length
appendLE32(&d, 1) // version
appendLE32(&d, 8) // message: plist
appendLE32(&d, tag)
d.append(body)
return d
}
static func decode(_ packet: Data) -> [String: Any]? {
guard packet.count >= 16 else { return nil }
let body = packet.dropFirst(16)
return (try? PropertyListSerialization.propertyList(
from: body, options: [], format: nil)) as? [String: Any]
}
static func appendLE32(_ d: inout Data, _ v: UInt32) {
for i in 0..<4 { d.append(UInt8((v >> (8 * i)) & 0xFF)) }
}
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) }
return v
}
}
@@ -0,0 +1,184 @@
import AVLiveWire
import CoreMedia
import CoreVideo
import Foundation
import VideoToolbox
/// HEVC decoder. Feed `VideoPayload`s in; receive `CVPixelBuffer`s via
/// `onFrame`. Keyframe payloads must carry the VPS/SPS/PPS parameter
/// sets prepended as 4-byte-length-prefixed NAL units (the layout the
/// iOS `VideoEncoder` emits); the decoder (re)builds its format
/// description from those.
final class VideoDecoder {
var onFrame: ((CVPixelBuffer) -> Void)?
private var session: VTDecompressionSession?
private var formatDesc: CMVideoFormatDescription?
/// Decode one access unit.
func decode(_ payload: VideoPayload) {
var au = payload.data
if payload.isKeyframe {
let (params, rest) = Self.splitParameterSets(au)
if !params.isEmpty {
rebuildFormat(params)
}
au = rest
}
guard let fmt = formatDesc, !au.isEmpty else { return }
if session == nil { makeSession(fmt) }
guard let session, let block = Self.blockBuffer(au) else {
return
}
var sample: CMSampleBuffer?
var sampleSize = au.count
guard CMSampleBufferCreateReady(
allocator: kCFAllocatorDefault, dataBuffer: block,
formatDescription: fmt, sampleCount: 1,
sampleTimingEntryCount: 0, sampleTimingArray: nil,
sampleSizeEntryCount: 1, sampleSizeArray: &sampleSize,
sampleBufferOut: &sample) == noErr, let sample else {
return
}
VTDecompressionSessionDecodeFrame(
session, sampleBuffer: sample, flags: [],
infoFlagsOut: nil) { [weak self] status, _, image, _, _ in
guard status == noErr, let image else { return }
self?.onFrame?(image)
}
}
func stop() {
if let session { VTDecompressionSessionInvalidate(session) }
session = nil
formatDesc = nil
}
deinit { stop() }
// MARK: - Helpers
/// Leading 4-byte-length-prefixed NAL units of HEVC parameter-set
/// type (VPS=32, SPS=33, PPS=34) are split from the frame data.
/// Returns (parameterSetData, frameData).
private static func splitParameterSets(_ data: Data)
-> (Data, Data) {
let bytes = [UInt8](data)
var offset = 0
var paramEnd = 0
while offset + 4 <= bytes.count {
let len = (Int(bytes[offset]) << 24)
| (Int(bytes[offset + 1]) << 16)
| (Int(bytes[offset + 2]) << 8)
| Int(bytes[offset + 3])
let nalStart = offset + 4
guard len > 0, nalStart + len <= bytes.count else { break }
let nalType = (Int(bytes[nalStart]) >> 1) & 0x3F
if nalType == 32 || nalType == 33 || nalType == 34 {
offset = nalStart + len
paramEnd = offset
} else {
break
}
}
return (data.prefix(paramEnd),
data.suffix(from: data.startIndex
.advanced(by: paramEnd)))
}
private func rebuildFormat(_ paramData: Data) {
var sets: [[UInt8]] = []
let bytes = [UInt8](paramData)
var offset = 0
while offset + 4 <= bytes.count {
let len = (Int(bytes[offset]) << 24)
| (Int(bytes[offset + 1]) << 16)
| (Int(bytes[offset + 2]) << 8)
| Int(bytes[offset + 3])
let start = offset + 4
guard len > 0, start + len <= bytes.count else { break }
sets.append(Array(bytes[start..<start + len]))
offset = start + len
}
guard sets.count >= 3 else { return }
var fmt: CMFormatDescription?
let status = withParameterSetPointers(sets) { pBuf, sBuf in
CMVideoFormatDescriptionCreateFromHEVCParameterSets(
allocator: kCFAllocatorDefault,
parameterSetCount: sets.count,
parameterSetPointers: pBuf,
parameterSetSizes: sBuf,
nalUnitHeaderLength: 4, extensions: nil,
formatDescriptionOut: &fmt)
}
if status == noErr, let fmt {
formatDesc = fmt
if let session { VTDecompressionSessionInvalidate(session) }
session = nil
}
}
/// Build the C-style parallel arrays of parameter-set pointers and
/// sizes that `CMVideoFormatDescriptionCreateFromHEVCParameterSets`
/// requires, keeping the backing storage alive for the call.
private func withParameterSetPointers(
_ sets: [[UInt8]],
_ body: (UnsafePointer<UnsafePointer<UInt8>>,
UnsafePointer<Int>) -> OSStatus) -> OSStatus {
func recurse(_ index: Int,
_ ptrs: inout [UnsafePointer<UInt8>],
_ sizes: inout [Int]) -> OSStatus {
if index == sets.count {
return ptrs.withUnsafeBufferPointer { pBuf in
sizes.withUnsafeBufferPointer { sBuf in
body(pBuf.baseAddress!, sBuf.baseAddress!)
}
}
}
return sets[index].withUnsafeBufferPointer { buf in
ptrs.append(buf.baseAddress!)
sizes.append(buf.count)
return recurse(index + 1, &ptrs, &sizes)
}
}
var ptrs: [UnsafePointer<UInt8>] = []
var sizes: [Int] = []
ptrs.reserveCapacity(sets.count)
sizes.reserveCapacity(sets.count)
return recurse(0, &ptrs, &sizes)
}
private func makeSession(_ fmt: CMVideoFormatDescription) {
let attrs: [CFString: Any] = [
kCVPixelBufferPixelFormatTypeKey:
kCVPixelFormatType_32BGRA,
]
VTDecompressionSessionCreate(
allocator: kCFAllocatorDefault, formatDescription: fmt,
decoderSpecification: nil,
imageBufferAttributes: attrs as CFDictionary,
outputCallback: nil, decompressionSessionOut: &session)
}
private static func blockBuffer(_ data: Data) -> CMBlockBuffer? {
var block: CMBlockBuffer?
guard CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault, memoryBlock: nil,
blockLength: data.count,
blockAllocator: kCFAllocatorDefault,
customBlockSource: nil, offsetToData: 0,
dataLength: data.count, flags: 0,
blockBufferOut: &block) == noErr, let block else {
return nil
}
var ok = false
data.withUnsafeBytes { raw in
if let base = raw.baseAddress,
CMBlockBufferReplaceDataBytes(
with: base, blockBuffer: block,
offsetIntoDestination: 0,
dataLength: data.count) == noErr { ok = true }
}
return ok ? block : nil
}
}
@@ -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))
}
}
@@ -0,0 +1,27 @@
import XCTest
@testable import AVLiveBody
final class USBMuxProtocolTests: XCTestCase {
func testEncodeWrapsPlistWith16ByteHeader() {
let body: [String: Any] = ["MessageType": "ListDevices"]
let packet = USBMuxProtocol.encode(plist: body, tag: 3)
XCTAssertGreaterThan(packet.count, 16)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 0).map(Int.init),
packet.count)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 4), 1)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 8), 8)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 12), 3)
}
func testDecodeRoundTrip() {
let packet = USBMuxProtocol.encode(
plist: ["MessageType": "Result", "Number": 0], tag: 1)
let decoded = USBMuxProtocol.decode(packet)
XCTAssertEqual(decoded?["MessageType"] as? String, "Result")
XCTAssertEqual(decoded?["Number"] as? Int, 0)
}
func testDecodeRejectsShortPacket() {
XCTAssertNil(USBMuxProtocol.decode(Data([0, 1, 2])))
}
}
+3
View File
@@ -46,3 +46,6 @@ targets:
- target: AVLiveBody
- package: AVLiveWire
product: AVLiveWire
settings:
base:
GENERATE_INFOPLIST_FILE: YES