Files
fegger c43dd3ca4a feat(ios): native receiver app (Swift, min iOS 17)
A native iOS receiver so an iPhone can act as the second receiver, speaking the existing signaling + RTP protocol (no C++ changes) and mirroring the Android receiver (Phase 8) source-to-source.

- RTP core (header/packet, jitter buffer, H.264 depacketizer) ported from the Android receiver
- BSD-socket signaling server (dual-stack, most-recent-peer, never-throwing sends) + NSBonjourServices
- VideoToolbox H.264 decode (in-band SPS/PPS, real-time, rebuilds on size change) -> AVSampleBufferDisplayLayer
- PLI keyframe recovery (500 ms) + pendingOffer for late surface attach
- XcodeGen project + bootstrap.sh; XCTest port of the Android suite + new coverage
- .gitignore for generated artifacts; CHANGELOG; PHASES + MEMORY updated

Status: authored; on-device validation pending a Mac + Xcode 26 + iPhone 16.
2026-09-10 21:31:53 +02:00

90 lines
3.4 KiB
Swift

import Foundation
/// JSON wire format shared with the C++ implementation: one JSON object per
/// newline-terminated TCP line (offer / answer / pli). Mirrors the C++
/// `SignalingMessage` and the Kotlin receiver.
enum SignalingMessage {
case offer(sessionId: String,
codec: String,
width: Int,
height: Int,
frameRateNum: Int,
frameRateDen: Int,
rtpAddress: String,
rtpPort: Int)
case answer(sessionId: String,
rtpAddress: String,
rtpPort: Int,
displayWidth: Int,
displayHeight: Int)
case pli(sessionId: String)
static let maxMessageBytes = 64 * 1024
static func parse(_ line: String) -> SignalingMessage? {
guard line.count <= maxMessageBytes else { return nil }
guard let data = line.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = obj["type"] as? String else {
return nil
}
switch type {
case "offer":
return .offer(
sessionId: obj["session_id"] as? String ?? "",
codec: obj["codec"] as? String ?? "",
width: obj["width"] as? Int ?? 0,
height: obj["height"] as? Int ?? 0,
frameRateNum: obj["frame_rate_num"] as? Int ?? 30,
frameRateDen: obj["frame_rate_den"] as? Int ?? 1,
rtpAddress: obj["rtp_address"] as? String ?? "",
rtpPort: obj["rtp_port"] as? Int ?? 0)
case "answer":
return .answer(
sessionId: obj["session_id"] as? String ?? "",
rtpAddress: obj["rtp_address"] as? String ?? "",
rtpPort: obj["rtp_port"] as? Int ?? 0,
displayWidth: obj["display_width"] as? Int ?? 0,
displayHeight: obj["display_height"] as? Int ?? 0)
case "pli":
return .pli(sessionId: obj["session_id"] as? String ?? "")
default:
return nil
}
}
static func serialize(_ message: SignalingMessage) -> String {
let json: [String: Any]
switch message {
case let .offer(sessionId, codec, width, height, frameRateNum, frameRateDen, rtpAddress, rtpPort):
json = [
"type": "offer",
"session_id": sessionId,
"codec": codec,
"width": width,
"height": height,
"frame_rate_num": frameRateNum,
"frame_rate_den": frameRateDen,
"rtp_address": rtpAddress,
"rtp_port": rtpPort,
]
case let .answer(sessionId, rtpAddress, rtpPort, displayWidth, displayHeight):
json = [
"type": "answer",
"session_id": sessionId,
"rtp_address": rtpAddress,
"rtp_port": rtpPort,
"display_width": displayWidth,
"display_height": displayHeight,
]
case let .pli(sessionId):
json = ["type": "pli", "session_id": sessionId]
}
guard let data = try? JSONSerialization.data(withJSONObject: json),
let s = String(data: data, encoding: .utf8) else {
return "{}\n" // unreachable for our message types; serialize is total
}
return s + "\n"
}
}