Compare commits
9 Commits
439e2505eb
...
e4e29aa5dd
| Author | SHA1 | Date |
|---|---|---|
|
|
e4e29aa5dd | 2 days ago |
|
|
c61c543a65 | 2 days ago |
|
|
1dc6b58d8a | 2 days ago |
|
|
7801d04b44 | 2 days ago |
|
|
7d885ecd57 | 2 days ago |
|
|
3e245b1ae3 | 3 days ago |
|
|
0ff5e2f907 | 3 days ago |
|
|
2527b18e8d | 3 days ago |
|
|
318d75ff69 | 3 days ago |
@ -0,0 +1,12 @@
|
||||
{ "images" : [
|
||||
{ "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
|
||||
{ "filename" : "icon_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
|
||||
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
|
||||
{ "filename" : "icon_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
|
||||
{ "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
|
||||
{ "filename" : "icon_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
|
||||
{ "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
|
||||
{ "filename" : "icon_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
|
||||
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
|
||||
{ "filename" : "icon_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
|
||||
], "info" : { "author" : "xcode", "version" : 1 } }
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 156 KiB |
@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
@ -0,0 +1,155 @@
|
||||
import AudioMatrix
|
||||
import Foundation
|
||||
import Intercom
|
||||
import MusubiCore
|
||||
import NDIKit
|
||||
import Observation
|
||||
|
||||
/// App-side glue for the intercom (control only): server on the clinic interface,
|
||||
/// PGM preview from an NDI proxy stream, talkback from the headset mic gated by the talk key.
|
||||
@MainActor @Observable
|
||||
final class IntercomController {
|
||||
let server = IntercomServer()
|
||||
|
||||
/// NDI source shown on the phones (usually PGM).
|
||||
var previewSourceName: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(previewSourceName, forKey: "intercomPreviewSource")
|
||||
startPreview()
|
||||
}
|
||||
}
|
||||
|
||||
/// Input device whose signal the phones hear while the talk key is down.
|
||||
var talkbackDeviceUID: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(talkbackDeviceUID, forKey: "talkbackDeviceUID")
|
||||
startTalkback()
|
||||
}
|
||||
}
|
||||
|
||||
private let settings: AppSettings
|
||||
private let audio: AudioController
|
||||
private var preview: NDIReceiver?
|
||||
private var tap: (feed: AudioFeed, deviceUID: String)?
|
||||
private var pump: TalkbackPump?
|
||||
|
||||
init(settings: AppSettings, audio: AudioController) {
|
||||
self.settings = settings
|
||||
self.audio = audio
|
||||
previewSourceName = UserDefaults.standard.string(forKey: "intercomPreviewSource") ?? ""
|
||||
talkbackDeviceUID = UserDefaults.standard.string(forKey: "talkbackDeviceUID") ?? ""
|
||||
}
|
||||
|
||||
var isEnabled: Bool { settings.role == .control && !settings.clinicInterfaceName.isEmpty }
|
||||
|
||||
/// Technical and clinic network on the same interface is fine at home, never on site.
|
||||
var sharesTechnicalInterface: Bool { settings.clinicInterfaceName == settings.interfaceName }
|
||||
|
||||
/// (Re)starts or stops everything according to role and clinic interface; call after settings changes.
|
||||
func restart() async {
|
||||
stopAll()
|
||||
guard isEnabled else { return }
|
||||
await server.start(interfaceName: settings.clinicInterfaceName)
|
||||
startPreview()
|
||||
startTalkback()
|
||||
}
|
||||
|
||||
func setTalkActive(_ active: Bool) {
|
||||
pump?.gateOpen = active
|
||||
}
|
||||
|
||||
private func stopAll() {
|
||||
server.stop()
|
||||
preview?.stop()
|
||||
preview = nil
|
||||
stopTalkback()
|
||||
}
|
||||
|
||||
private func startPreview() {
|
||||
preview?.stop()
|
||||
preview = nil
|
||||
guard isEnabled, !previewSourceName.isEmpty else { return }
|
||||
let input = server.input
|
||||
preview = NDIReceiver(source: Source(ndiName: previewSourceName), receiverName: "Musubi Intercom",
|
||||
bandwidth: .lowest, onFrame: { input.pushVideo($0) })
|
||||
Log.intercom.info("Preview from \(self.previewSourceName, privacy: .public)")
|
||||
}
|
||||
|
||||
private func startTalkback() {
|
||||
stopTalkback()
|
||||
guard isEnabled else { return }
|
||||
let uid = talkbackDeviceUID.isEmpty ? defaultTalkbackDevice() : talkbackDeviceUID
|
||||
guard let uid, audio.matrix.inputDevices.contains(where: { $0.uid == uid }) else { return }
|
||||
let feed = audio.matrix.addInputTap(deviceUID: uid)
|
||||
tap = (feed, uid)
|
||||
let pump = TalkbackPump(feed: feed, input: server.input)
|
||||
pump.gateOpen = audio.talkKey.isActive
|
||||
pump.start()
|
||||
self.pump = pump
|
||||
}
|
||||
|
||||
private func stopTalkback() {
|
||||
pump?.stop()
|
||||
pump = nil
|
||||
if let tap { audio.matrix.removeInputTap(tap.feed, deviceUID: tap.deviceUID) }
|
||||
tap = nil
|
||||
}
|
||||
|
||||
/// The mic already routed in the sound matrix, else the system's default input.
|
||||
func defaultTalkbackDevice() -> String? {
|
||||
for route in audio.matrix.routes {
|
||||
if case .input(let uid) = route.source { return uid }
|
||||
}
|
||||
return AudioDevices.defaultInput()?.uid ?? audio.matrix.inputDevices.first?.uid
|
||||
}
|
||||
|
||||
/// Name of the device "Automatisch" currently resolves to.
|
||||
var automaticTalkbackName: String? {
|
||||
defaultTalkbackDevice().flatMap { uid in audio.matrix.inputDevices.first { $0.uid == uid }?.name }
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves mic audio from the tap to the Opus encoder every 10 ms on its own queue.
|
||||
/// Sends silence while the talk key is up, so the phones' audio clock keeps running.
|
||||
private final class TalkbackPump: @unchecked Sendable {
|
||||
private let feed: AudioFeed
|
||||
private let input: IntercomInput
|
||||
private let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.talkback", qos: .userInitiated)
|
||||
private var timer: DispatchSourceTimer?
|
||||
private let lock = NSLock()
|
||||
private var open = false
|
||||
private var stereo = [Float](repeating: 0, count: 4096 * 2)
|
||||
private var mono = [Float](repeating: 0, count: 4096)
|
||||
|
||||
init(feed: AudioFeed, input: IntercomInput) {
|
||||
self.feed = feed
|
||||
self.input = input
|
||||
}
|
||||
|
||||
var gateOpen: Bool {
|
||||
get { lock.withLock { open } }
|
||||
set { lock.withLock { open = newValue } }
|
||||
}
|
||||
|
||||
func start() {
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now(), repeating: .milliseconds(10), leeway: .milliseconds(2))
|
||||
timer.setEventHandler { [weak self] in self?.drain() }
|
||||
timer.resume()
|
||||
self.timer = timer
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.cancel()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func drain() {
|
||||
let rate = feed.currentSampleRate
|
||||
let frames = stereo.withUnsafeMutableBufferPointer { feed.read(into: $0.baseAddress!, frames: 4096) }
|
||||
guard frames > 0, rate > 0 else { return }
|
||||
let open = gateOpen
|
||||
for i in 0..<frames { mono[i] = open ? stereo[i * 2] : 0 }
|
||||
mono.withUnsafeBufferPointer { input.pushTalkback(UnsafeBufferPointer(rebasing: $0[0..<frames]), sampleRate: rate) }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,163 @@
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import DesignSystem
|
||||
import Intercom
|
||||
import SwiftUI
|
||||
|
||||
/// Intercom on the control: QR code for the phones, who is connected, what they see and hear.
|
||||
struct IntercomView: View {
|
||||
let intercom: IntercomController
|
||||
let sourceList: SourceListModel
|
||||
let audio: AudioController
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
|
||||
private var server: IntercomServer { intercom.server }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Theme.Space.card) {
|
||||
if !intercom.isEnabled {
|
||||
VStack(alignment: .leading, spacing: Theme.Space.unit) {
|
||||
Text("Intercom ist aus. Wähl in den Einstellungen das Klinik-Netz, dann verbinden sich Handys per QR-Code.")
|
||||
.font(Theme.Font.body)
|
||||
.foregroundStyle(Theme.Color.nezumi)
|
||||
Button("Einstellungen öffnen") { openSettings() }
|
||||
}
|
||||
} else {
|
||||
if let error = server.error {
|
||||
Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku)
|
||||
}
|
||||
if intercom.sharesTechnicalInterface {
|
||||
Text("Technik- und Klinik-Netz laufen über dieselbe Schnittstelle. Zum Testen okay, im Einsatz müssen sie getrennt sein.")
|
||||
.font(Theme.Font.caption)
|
||||
.foregroundStyle(Theme.Color.kohaku)
|
||||
}
|
||||
HStack(alignment: .top, spacing: Theme.Space.card) {
|
||||
joinCard
|
||||
VStack(alignment: .leading, spacing: Theme.Space.card) {
|
||||
sourcesCard
|
||||
participantsCard
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Theme.Space.card)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Theme.Color.sumi)
|
||||
}
|
||||
|
||||
private var joinCard: some View {
|
||||
Card {
|
||||
Text("Handy verbinden").font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
|
||||
if let url = server.url {
|
||||
QRCode(text: url.absoluteString)
|
||||
.frame(width: 200, height: 200)
|
||||
Text(verbatim: url.absoluteString)
|
||||
.font(Theme.Font.number)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
.foregroundStyle(Theme.Color.washi)
|
||||
.textSelection(.enabled)
|
||||
Text("Handy ins Klinik-Netz, QR-Code scannen, Name und Rolle eintragen.")
|
||||
.font(Theme.Font.caption)
|
||||
.foregroundStyle(Theme.Color.nezumi)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
Text("Startet …").font(Theme.Font.body).foregroundStyle(Theme.Color.nezumi)
|
||||
}
|
||||
}
|
||||
.frame(width: 240)
|
||||
}
|
||||
|
||||
private var sourcesCard: some View {
|
||||
Card {
|
||||
Picker("Vorschau", selection: Binding(get: { intercom.previewSourceName }, set: { intercom.previewSourceName = $0 })) {
|
||||
Text("Keine").tag("")
|
||||
ForEach(sourceList.sources, id: \.ndiName) { source in
|
||||
Text(verbatim: source.ndiName).tag(source.ndiName)
|
||||
}
|
||||
if !intercom.previewSourceName.isEmpty, !sourceList.sources.contains(where: { $0.ndiName == intercom.previewSourceName }) {
|
||||
Text(verbatim: "\(intercom.previewSourceName) · nicht gefunden").tag(intercom.previewSourceName)
|
||||
}
|
||||
}
|
||||
Picker("Talkback", selection: Binding(get: { intercom.talkbackDeviceUID }, set: { intercom.talkbackDeviceUID = $0 })) {
|
||||
if let name = intercom.automaticTalkbackName {
|
||||
Text("Automatisch · \(name)").tag("")
|
||||
} else {
|
||||
Text("Automatisch").tag("")
|
||||
}
|
||||
ForEach(audio.matrix.inputDevices) { device in
|
||||
Text(verbatim: device.name).tag(device.uid)
|
||||
}
|
||||
}
|
||||
Text("Handys hören das Mikro, solange die Sprechtaste (\(audio.talkKey.displayName)) gedrückt ist.")
|
||||
.font(Theme.Font.caption)
|
||||
.foregroundStyle(Theme.Color.nezumi)
|
||||
}
|
||||
}
|
||||
|
||||
private var participantsCard: some View {
|
||||
Card {
|
||||
HStack {
|
||||
Text("Verbunden").font(Theme.Font.title).foregroundStyle(Theme.Color.washi)
|
||||
Spacer()
|
||||
Text(verbatim: "\(server.participants.filter(\.connected).count)")
|
||||
.font(Theme.Font.number)
|
||||
.foregroundStyle(Theme.Color.nezumi)
|
||||
}
|
||||
if server.participants.isEmpty {
|
||||
Text("Noch niemand da. Scann den QR-Code mit einem Handy.")
|
||||
.font(Theme.Font.body)
|
||||
.foregroundStyle(Theme.Color.nezumi)
|
||||
}
|
||||
ForEach(server.participants) { participant in
|
||||
HStack(spacing: Theme.Space.unit) {
|
||||
Circle()
|
||||
.fill(participant.connected ? Theme.Color.moegi : Theme.Color.kohaku)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(verbatim: participant.name).font(Theme.Font.body).foregroundStyle(Theme.Color.washi)
|
||||
Text(verbatim: participant.role).font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi)
|
||||
Spacer()
|
||||
if !participant.connected {
|
||||
Text("verbindet …").font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Card<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Space.unit) { content }
|
||||
.padding(Theme.Space.card)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.card))
|
||||
.overlay(RoundedRectangle(cornerRadius: Theme.Radius.card).stroke(Theme.Color.usuzumi, lineWidth: Theme.Stroke.line))
|
||||
}
|
||||
}
|
||||
|
||||
/// Black on white with a quiet zone: what phone cameras read best, regardless of the dark theme.
|
||||
private struct QRCode: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
if let image = Self.image(for: text) {
|
||||
Image(decorative: image, scale: 1)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.padding(Theme.Space.unit)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: Theme.Radius.button))
|
||||
}
|
||||
}
|
||||
|
||||
static func image(for text: String) -> CGImage? {
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(text.utf8)
|
||||
filter.correctionLevel = "M"
|
||||
guard let output = filter.outputImage else { return nil }
|
||||
return CIContext().createCGImage(output, from: output.extent)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
struct HTTPRequest: Equatable {
|
||||
var method: String
|
||||
var path: String
|
||||
var headers: [String: String]
|
||||
var body: [UInt8]
|
||||
}
|
||||
|
||||
struct HTTPResponse {
|
||||
var status: Int
|
||||
var contentType: String
|
||||
var body: [UInt8]
|
||||
|
||||
static func text(_ status: Int, _ text: String) -> HTTPResponse {
|
||||
HTTPResponse(status: status, contentType: "text/plain; charset=utf-8", body: Array(text.utf8))
|
||||
}
|
||||
|
||||
static func json(_ value: some Encodable) -> HTTPResponse {
|
||||
HTTPResponse(status: 200, contentType: "application/json", body: Array((try? JSONEncoder().encode(value)) ?? Data()))
|
||||
}
|
||||
|
||||
var serialized: [UInt8] {
|
||||
let reason = [200: "OK", 204: "No Content", 400: "Bad Request", 404: "Not Found", 405: "Method Not Allowed",
|
||||
413: "Payload Too Large", 503: "Service Unavailable"][status] ?? "Error"
|
||||
let head = "HTTP/1.1 \(status) \(reason)\r\nContent-Type: \(contentType)\r\nContent-Length: \(body.count)\r\n"
|
||||
+ "Cache-Control: no-store\r\nConnection: close\r\n\r\n"
|
||||
return Array(head.utf8) + body
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental HTTP/1.1 request parser (no chunked bodies; the phone page only sends small JSON).
|
||||
struct HTTPRequestParser {
|
||||
enum ParseError: Error { case malformed, tooLarge }
|
||||
|
||||
static let maxHeader = 8 * 1024
|
||||
static let maxBody = 64 * 1024
|
||||
|
||||
private var buffer: [UInt8] = []
|
||||
|
||||
/// Appends bytes; returns the request once it is complete.
|
||||
mutating func append(_ bytes: [UInt8]) throws -> HTTPRequest? {
|
||||
buffer += bytes
|
||||
guard let headerEnd = Self.find([13, 10, 13, 10], in: buffer) else {
|
||||
if buffer.count > Self.maxHeader { throw ParseError.tooLarge }
|
||||
return nil
|
||||
}
|
||||
guard let head = String(bytes: buffer[0..<headerEnd], encoding: .utf8) else { throw ParseError.malformed }
|
||||
let lines = head.components(separatedBy: "\r\n")
|
||||
let requestLine = lines[0].split(separator: " ")
|
||||
guard requestLine.count == 3, requestLine[2].hasPrefix("HTTP/1.") else { throw ParseError.malformed }
|
||||
var headers: [String: String] = [:]
|
||||
for line in lines.dropFirst() {
|
||||
guard let colon = line.firstIndex(of: ":") else { continue }
|
||||
headers[line[..<colon].lowercased()] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
let length = Int(headers["content-length"] ?? "0") ?? -1
|
||||
guard length >= 0 else { throw ParseError.malformed }
|
||||
guard length <= Self.maxBody else { throw ParseError.tooLarge }
|
||||
let bodyStart = headerEnd + 4
|
||||
guard buffer.count >= bodyStart + length else { return nil }
|
||||
let path = String(requestLine[1].split(separator: "?", maxSplits: 1).first ?? "/")
|
||||
return HTTPRequest(method: String(requestLine[0]), path: path, headers: headers,
|
||||
body: Array(buffer[bodyStart..<bodyStart + length]))
|
||||
}
|
||||
|
||||
private static func find(_ needle: [UInt8], in haystack: [UInt8]) -> Int? {
|
||||
guard haystack.count >= needle.count else { return nil }
|
||||
for i in 0...(haystack.count - needle.count) where haystack[i] == needle[0] {
|
||||
if Array(haystack[i..<i + needle.count]) == needle { return i }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal HTTP server bound to one interface. One request per connection.
|
||||
final class HTTPServer: @unchecked Sendable {
|
||||
typealias Handler = @Sendable (HTTPRequest) -> HTTPResponse
|
||||
|
||||
private let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.http")
|
||||
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
|
||||
private var listener: NWListener?
|
||||
private let handler: Handler
|
||||
|
||||
init(handler: @escaping Handler) {
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
func start(interface: NWInterface, port: UInt16) async throws {
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.requiredInterface = interface
|
||||
parameters.allowLocalEndpointReuse = true
|
||||
parameters.includePeerToPeer = false
|
||||
let listener = try NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port) ?? .any)
|
||||
let once = OnceFlag()
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
listener.stateUpdateHandler = { [log] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
if once.claim() { continuation.resume() }
|
||||
case .failed(let error):
|
||||
log.error("HTTP failed: \(error.localizedDescription, privacy: .public)")
|
||||
if once.claim() { continuation.resume(throwing: error) }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
listener.newConnectionHandler = { [weak self] connection in self?.serve(connection) }
|
||||
listener.start(queue: queue)
|
||||
}
|
||||
self.listener = listener
|
||||
}
|
||||
|
||||
func stop() {
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
}
|
||||
|
||||
private func serve(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
read(connection, parser: ParserBox())
|
||||
}
|
||||
|
||||
private func read(_ connection: NWConnection, parser: ParserBox) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, isComplete, error in
|
||||
guard let self else { return }
|
||||
do {
|
||||
if let data, let request = try parser.value.append(Array(data)) {
|
||||
// Signalling may block briefly (session setup); keep the HTTP queue free.
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.respond(self.handler(request), on: connection)
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch HTTPRequestParser.ParseError.tooLarge {
|
||||
respond(.text(413, "Zu groß."), on: connection)
|
||||
return
|
||||
} catch {
|
||||
respond(.text(400, "Ungültige Anfrage."), on: connection)
|
||||
return
|
||||
}
|
||||
if isComplete || error != nil { connection.cancel() } else { read(connection, parser: parser) }
|
||||
}
|
||||
}
|
||||
|
||||
private func respond(_ response: HTTPResponse, on connection: NWConnection) {
|
||||
connection.send(content: Data(response.serialized), completion: .contentProcessed { _ in connection.cancel() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-connection parser state; only touched on the HTTP queue.
|
||||
private final class ParserBox: @unchecked Sendable {
|
||||
var value = HTTPRequestParser()
|
||||
}
|
||||
@ -0,0 +1,218 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
/// Media transport for all phones: one UDP port on the clinic interface, demultiplexed into sessions.
|
||||
/// Everything runs on one serial queue; public methods may be called from any thread.
|
||||
final class IntercomEngine: @unchecked Sendable {
|
||||
struct SessionInfo: Sendable, Equatable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let role: String
|
||||
let connected: Bool
|
||||
}
|
||||
|
||||
var onSessionsChanged: (@Sendable ([SessionInfo]) -> Void)?
|
||||
var onKeyFrameNeeded: (@Sendable () -> Void)?
|
||||
|
||||
let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.media", qos: .userInteractive)
|
||||
private let certificate: DTLSCertificate
|
||||
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
|
||||
|
||||
// queue only
|
||||
private var listener: NWListener?
|
||||
private var sessions: [UUID: PeerSession] = [:]
|
||||
private var connections: [UUID: NWConnection] = [:]
|
||||
private var timer: DispatchSourceTimer?
|
||||
|
||||
init(certificate: DTLSCertificate) {
|
||||
self.certificate = certificate
|
||||
}
|
||||
|
||||
/// Binds the media port; returns once listening (or throws).
|
||||
func start(interface: NWInterface, port: UInt16) async throws {
|
||||
let parameters = NWParameters.udp
|
||||
parameters.requiredInterface = interface
|
||||
parameters.allowLocalEndpointReuse = true
|
||||
let listener = try NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port) ?? .any)
|
||||
let once = OnceFlag()
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
listener.stateUpdateHandler = { [log] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
if once.claim() { continuation.resume() }
|
||||
case .failed(let error):
|
||||
log.error("Media port failed: \(error.localizedDescription, privacy: .public)")
|
||||
if once.claim() { continuation.resume(throwing: error) }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
listener.newConnectionHandler = { [weak self] connection in self?.accept(connection) }
|
||||
listener.start(queue: queue)
|
||||
queue.async { self.listener = listener }
|
||||
}
|
||||
queue.async { self.startTimer() }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.sync {
|
||||
timer?.cancel()
|
||||
timer = nil
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
connections.values.forEach { $0.cancel() }
|
||||
connections = [:]
|
||||
sessions = [:]
|
||||
}
|
||||
onSessionsChanged?([])
|
||||
}
|
||||
|
||||
/// Creates a session for a browser offer and returns its id and the SDP answer.
|
||||
func createSession(offer: String, name: String, role: String, address: String, port: UInt16) throws -> (UUID, String)? {
|
||||
let accepted = try PeerSession.accept(offerSDP: offer, name: name, role: role, certificate: certificate,
|
||||
address: address, port: port)
|
||||
guard let accepted else { return nil }
|
||||
queue.sync {
|
||||
sessions[accepted.session.id] = accepted.session
|
||||
publish()
|
||||
}
|
||||
log.info("Session for \(name, privacy: .public) (\(role, privacy: .public)) created")
|
||||
return (accepted.session.id, accepted.answer)
|
||||
}
|
||||
|
||||
func removeSession(_ id: UUID) {
|
||||
queue.async {
|
||||
self.sessions[id] = nil
|
||||
self.connections.removeValue(forKey: id)?.cancel()
|
||||
self.publish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends one Opus packet (20 ms) to every connected phone.
|
||||
func sendAudio(opus: [UInt8], timestamp: UInt32) {
|
||||
queue.async {
|
||||
for (id, session) in self.sessions {
|
||||
guard let packet = session.audioPacket(opus: opus, timestamp: timestamp) else { continue }
|
||||
self.connections[id]?.send(content: Data(packet), completion: .idempotent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends one H.264 access unit to every connected phone.
|
||||
func sendVideo(nalUnits: [[UInt8]], timestamp: UInt32) {
|
||||
let payloads = H264Packetizer.payloads(for: nalUnits)
|
||||
queue.async {
|
||||
for (id, session) in self.sessions {
|
||||
guard let connection = self.connections[id] else { continue }
|
||||
for packet in session.videoPackets(payloads: payloads, timestamp: timestamp) {
|
||||
connection.send(content: Data(packet), completion: .idempotent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - queue
|
||||
|
||||
private func accept(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
receive(on: connection)
|
||||
}
|
||||
|
||||
private func receive(on connection: NWConnection) {
|
||||
connection.receiveMessage { [weak self] data, _, _, error in
|
||||
guard let self else { return }
|
||||
if let data, !data.isEmpty { self.handle(Array(data), from: connection) }
|
||||
if error == nil { self.receive(on: connection) } else { connection.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ packet: [UInt8], from connection: NWConnection) {
|
||||
switch PeerSession.classify(packet) {
|
||||
case .stun:
|
||||
guard let message = STUN.parse(packet),
|
||||
case .hostPort(let host, let port) = connection.endpoint else { return }
|
||||
let address: [UInt8]
|
||||
switch host {
|
||||
case .ipv4(let v4): address = Array(v4.rawValue)
|
||||
case .ipv6(let v6):
|
||||
let raw = Array(v6.rawValue)
|
||||
// IPv4-mapped (::ffff:a.b.c.d) on a dual-stack socket.
|
||||
address = raw.prefix(12).elementsEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF]) ? Array(raw.suffix(4)) : raw
|
||||
default: return
|
||||
}
|
||||
let localUfrag = message.username?.split(separator: ":").first.map(String.init)
|
||||
guard let session = sessions.values.first(where: { $0.localUfrag == localUfrag }),
|
||||
let reply = session.answerSTUN(packet, message: message, address: address, port: port.rawValue) else { return }
|
||||
if connections[session.id] !== connection {
|
||||
connections[session.id] = connection
|
||||
log.info("\(session.name, privacy: .public) reachable via \(String(describing: connection.endpoint), privacy: .public)")
|
||||
}
|
||||
connection.send(content: Data(reply), completion: .idempotent)
|
||||
|
||||
case .dtls:
|
||||
guard let (id, session) = session(for: connection) else { return }
|
||||
let result = session.receiveDTLS(packet)
|
||||
for reply in result.replies { connection.send(content: Data(reply), completion: .idempotent) }
|
||||
if result.becameReady {
|
||||
log.info("\(session.name, privacy: .public) connected (DTLS-SRTP)")
|
||||
publish()
|
||||
onKeyFrameNeeded?()
|
||||
} else if case .failed(let reason) = session.dtlsState {
|
||||
log.error("DTLS with \(session.name, privacy: .public) failed: \(reason, privacy: .public)")
|
||||
sessions[id] = nil
|
||||
connections[id] = nil
|
||||
publish()
|
||||
}
|
||||
|
||||
case .rtcp:
|
||||
if let (_, session) = session(for: connection), session.receiveRTCP(packet) { onKeyFrameNeeded?() }
|
||||
|
||||
case .other:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func session(for connection: NWConnection) -> (UUID, PeerSession)? {
|
||||
guard let id = connections.first(where: { $0.value === connection })?.key, let session = sessions[id] else { return nil }
|
||||
return (id, session)
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + 1, repeating: 1)
|
||||
timer.setEventHandler { [weak self] in self?.tick() }
|
||||
timer.resume()
|
||||
self.timer = timer
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let now = Date()
|
||||
var changed = false
|
||||
for (id, session) in sessions {
|
||||
if session.isExpired(now: now) {
|
||||
log.info("\(session.name, privacy: .public) gone (no consent)")
|
||||
sessions[id] = nil
|
||||
connections.removeValue(forKey: id)?.cancel()
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if let connection = connections[id] {
|
||||
for report in session.senderReports(now: now) { connection.send(content: Data(report), completion: .idempotent) }
|
||||
}
|
||||
}
|
||||
if changed { publish() }
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
let infos = sessions.values
|
||||
.sorted { $0.created < $1.created }
|
||||
.map { SessionInfo(id: $0.id, name: $0.name, role: $0.role, connected: $0.isMediaReady) }
|
||||
onSessionsChanged?(infos)
|
||||
}
|
||||
}
|
||||
|
||||
final class OnceFlag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
@ -0,0 +1,238 @@
|
||||
import CoreVideo
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Intercom stage 1 (M4): phones on the clinic interface open a page, hear talkback and see PGM.
|
||||
/// Web server and media port bind only to the chosen interface.
|
||||
@MainActor @Observable
|
||||
public final class IntercomServer {
|
||||
public struct Participant: Identifiable, Equatable, Sendable {
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
public let role: String
|
||||
public let connected: Bool
|
||||
}
|
||||
|
||||
public static let httpPort: UInt16 = 8080
|
||||
public static let mediaPort: UInt16 = 50_000
|
||||
|
||||
public private(set) var participants: [Participant] = []
|
||||
/// Address phones open (also encoded in the QR code).
|
||||
public private(set) var url: URL?
|
||||
public private(set) var error: String?
|
||||
public private(set) var isRunning = false
|
||||
|
||||
/// Thread-safe media input (PGM frames, talkback audio).
|
||||
public nonisolated let input: IntercomInput
|
||||
|
||||
private var engine: IntercomEngine?
|
||||
private var http: HTTPServer?
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
|
||||
|
||||
public init() {
|
||||
input = IntercomInput()
|
||||
}
|
||||
|
||||
public func start(interfaceName: String) async {
|
||||
stop()
|
||||
guard let interface = await Self.interface(named: interfaceName), let address = Self.ipv4Address(of: interfaceName) else {
|
||||
error = "Klinik-Schnittstelle \(interfaceName) hat keine Adresse. Neuer Versuch in 5 s."
|
||||
log.error("Intercom: interface \(interfaceName, privacy: .public) unavailable")
|
||||
scheduleRetry(interfaceName)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let engine = IntercomEngine(certificate: try DTLSCertificate())
|
||||
engine.onSessionsChanged = { [weak self, input] infos in
|
||||
input.hasListeners = infos.contains { $0.connected }
|
||||
Task { @MainActor in
|
||||
self?.participants = infos.map { Participant(id: $0.id, name: $0.name, role: $0.role, connected: $0.connected) }
|
||||
}
|
||||
}
|
||||
engine.onKeyFrameNeeded = { [input] in input.requestKeyFrame() }
|
||||
try await engine.start(interface: interface, port: Self.mediaPort)
|
||||
|
||||
let mediaPort = Self.mediaPort
|
||||
let http = HTTPServer { [weak engine] request in
|
||||
guard let engine else { return .text(503, "Musubi startet neu.") }
|
||||
return Self.route(request, engine: engine, address: address, mediaPort: mediaPort)
|
||||
}
|
||||
try await http.start(interface: interface, port: Self.httpPort)
|
||||
|
||||
input.attach(engine)
|
||||
self.engine = engine
|
||||
self.http = http
|
||||
url = URL(string: "http://\(address):\(Self.httpPort)/")
|
||||
isRunning = true
|
||||
error = nil
|
||||
log.info("Intercom on \(interfaceName, privacy: .public) at \(address, privacy: .public)")
|
||||
} catch {
|
||||
stop()
|
||||
self.error = "Intercom lässt sich auf \(interfaceName) nicht starten. Neuer Versuch in 5 s."
|
||||
log.error("Intercom start failed: \(error.localizedDescription, privacy: .public)")
|
||||
scheduleRetry(interfaceName)
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
input.attach(nil)
|
||||
http?.stop()
|
||||
http = nil
|
||||
engine?.stop()
|
||||
engine = nil
|
||||
participants = []
|
||||
url = nil
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
private func scheduleRetry(_ interfaceName: String) {
|
||||
retryTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.start(interfaceName: interfaceName)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Signalling
|
||||
|
||||
private struct OfferRequest: Decodable { let name: String; let role: String; let sdp: String }
|
||||
private struct OfferResponse: Encodable { let id: UUID; let sdp: String }
|
||||
private struct LeaveRequest: Decodable { let id: UUID }
|
||||
|
||||
nonisolated private static func route(_ request: HTTPRequest, engine: IntercomEngine, address: String, mediaPort: UInt16) -> HTTPResponse {
|
||||
switch (request.method, request.path) {
|
||||
case ("GET", "/"), ("GET", "/index.html"):
|
||||
return HTTPResponse(status: 200, contentType: "text/html; charset=utf-8", body: Array(PhonePage.html.utf8))
|
||||
case ("POST", "/api/offer"):
|
||||
guard let offer = try? JSONDecoder().decode(OfferRequest.self, from: Data(request.body)) else {
|
||||
return .text(400, "Angebot unlesbar.")
|
||||
}
|
||||
let name = String(offer.name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40))
|
||||
let role = String(offer.role.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40))
|
||||
guard !name.isEmpty, !role.isEmpty else { return .text(400, "Name und Rolle fehlen.") }
|
||||
do {
|
||||
guard let (id, sdp) = try engine.createSession(offer: offer.sdp, name: name, role: role,
|
||||
address: address, port: mediaPort) else {
|
||||
return .text(400, "Dein Browser kann weder Opus noch H.264 empfangen.")
|
||||
}
|
||||
return .json(OfferResponse(id: id, sdp: sdp))
|
||||
} catch {
|
||||
return .text(400, "Angebot unvollständig.")
|
||||
}
|
||||
case ("POST", "/api/leave"):
|
||||
if let leave = try? JSONDecoder().decode(LeaveRequest.self, from: Data(request.body)) { engine.removeSession(leave.id) }
|
||||
return HTTPResponse(status: 204, contentType: "text/plain", body: [])
|
||||
case (_, "/"), (_, "/api/offer"), (_, "/api/leave"):
|
||||
return .text(405, "Methode nicht erlaubt.")
|
||||
default:
|
||||
return .text(404, "Nicht gefunden.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interfaces
|
||||
|
||||
private static func interface(named name: String) async -> NWInterface? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let monitor = NWPathMonitor()
|
||||
let once = OnceFlag()
|
||||
monitor.pathUpdateHandler = { path in
|
||||
guard once.claim() else { return }
|
||||
monitor.cancel()
|
||||
continuation.resume(returning: path.availableInterfaces.first { $0.name == name })
|
||||
}
|
||||
monitor.start(queue: .global(qos: .utility))
|
||||
}
|
||||
}
|
||||
|
||||
/// First IPv4 address of a BSD interface (e.g. "en0").
|
||||
nonisolated static func ipv4Address(of interfaceName: String) -> String? {
|
||||
var list: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&list) == 0, let first = list else { return nil }
|
||||
defer { freeifaddrs(list) }
|
||||
for entry in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let ifa = entry.pointee
|
||||
guard String(cString: ifa.ifa_name) == interfaceName, let addr = ifa.ifa_addr,
|
||||
addr.pointee.sa_family == UInt8(AF_INET) else { continue }
|
||||
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
guard getnameinfo(addr, socklen_t(addr.pointee.sa_len), &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue }
|
||||
return String(decoding: host.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Media input for the intercom; safe to call from any thread.
|
||||
public final class IntercomInput: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var engine: IntercomEngine?
|
||||
private var listeners = false
|
||||
private var talkback = TalkbackEncoder()
|
||||
private let preview = PreviewEncoder()
|
||||
private var resamplePosition: Double = 0
|
||||
private var resampleLast: Float = 0
|
||||
private var scratch: [Float] = []
|
||||
|
||||
init() {
|
||||
preview.onAccessUnit = { [weak self] nalUnits, timestamp in
|
||||
self?.currentEngine?.sendVideo(nalUnits: nalUnits, timestamp: timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
var hasListeners: Bool {
|
||||
get { lock.withLock { listeners } }
|
||||
set { lock.withLock { listeners = newValue } }
|
||||
}
|
||||
|
||||
private var currentEngine: IntercomEngine? { lock.withLock { engine } }
|
||||
|
||||
func attach(_ engine: IntercomEngine?) {
|
||||
lock.withLock {
|
||||
self.engine = engine
|
||||
listeners = false
|
||||
}
|
||||
}
|
||||
|
||||
func requestKeyFrame() { preview.requestKeyFrame() }
|
||||
|
||||
/// One PGM frame (any size, BGRA or UYVY). Dropped while no phone is connected.
|
||||
public func pushVideo(_ pixelBuffer: CVPixelBuffer) {
|
||||
guard hasListeners else { return }
|
||||
preview.encode(pixelBuffer)
|
||||
}
|
||||
|
||||
/// Talkback audio, mono, any sample rate. Pass silence while the talk key is up so the stream keeps its timing.
|
||||
/// Call from one thread only (not the audio render thread).
|
||||
public func pushTalkback(_ samples: UnsafeBufferPointer<Float>, sampleRate: Double) {
|
||||
guard let engine = currentEngine, hasListeners, let talkback else { return }
|
||||
let packets: [([UInt8], UInt32)]
|
||||
if sampleRate == TalkbackEncoder.sampleRate {
|
||||
packets = talkback.append(samples)
|
||||
} else {
|
||||
resample(samples, from: sampleRate)
|
||||
packets = scratch.withUnsafeBufferPointer { talkback.append($0) }
|
||||
}
|
||||
for (packet, timestamp) in packets { engine.sendAudio(opus: packet, timestamp: timestamp) }
|
||||
}
|
||||
|
||||
/// Linear interpolation to 48 kHz; good enough for speech.
|
||||
private func resample(_ input: UnsafeBufferPointer<Float>, from rate: Double) {
|
||||
scratch.removeAll(keepingCapacity: true)
|
||||
let step = rate / TalkbackEncoder.sampleRate
|
||||
var position = resamplePosition
|
||||
while position < Double(input.count) {
|
||||
let index = Int(position)
|
||||
let fraction = Float(position - Double(index))
|
||||
let previous = index == 0 ? resampleLast : input[index - 1]
|
||||
scratch.append(previous + (input[index] - previous) * fraction)
|
||||
position += step
|
||||
}
|
||||
resamplePosition = position - Double(input.count)
|
||||
resampleLast = input.last ?? resampleLast
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
import CoreMedia
|
||||
import QuartzCore
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import OSLog
|
||||
import VideoToolbox
|
||||
|
||||
/// Encodes the PGM preview for phones: scaled down, 25 fps, H.264 constrained baseline, no B-frames.
|
||||
/// `encode` may be called from the NDI receive thread; work happens on an own queue.
|
||||
final class PreviewEncoder: @unchecked Sendable {
|
||||
/// Called with the NAL units of one access unit and its 90 kHz timestamp.
|
||||
var onAccessUnit: (@Sendable ([[UInt8]], UInt32) -> Void)?
|
||||
|
||||
let width: Int32
|
||||
let height: Int32
|
||||
let frameRate: Double
|
||||
private let bitRate: Int
|
||||
private let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.video", qos: .userInitiated)
|
||||
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
|
||||
|
||||
// queue only
|
||||
private var session: VTCompressionSession?
|
||||
private var transfer: VTPixelTransferSession?
|
||||
private var pool: CVPixelBufferPool?
|
||||
private var forceKeyFrame = true
|
||||
private var lastFrameTime: Double = -1
|
||||
private var busy = false
|
||||
private let start = CACurrentMediaTime()
|
||||
|
||||
init(width: Int32 = 960, height: Int32 = 540, frameRate: Double = 25, bitRate: Int = 1_200_000) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.frameRate = frameRate
|
||||
self.bitRate = bitRate
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let session { VTCompressionSessionInvalidate(session) }
|
||||
if let transfer { VTPixelTransferSessionInvalidate(transfer) }
|
||||
}
|
||||
|
||||
func requestKeyFrame() {
|
||||
queue.async { self.forceKeyFrame = true }
|
||||
}
|
||||
|
||||
/// Offers a frame; frames above the target rate, or while the encoder is still busy, are dropped.
|
||||
func encode(_ pixelBuffer: CVPixelBuffer) {
|
||||
let now = CACurrentMediaTime() - start
|
||||
// CVPixelBuffer is immutable once handed over by the receiver.
|
||||
nonisolated(unsafe) let pixelBuffer = pixelBuffer
|
||||
queue.async {
|
||||
guard !self.busy, now - self.lastFrameTime >= 1 / self.frameRate - 0.004 else { return }
|
||||
self.lastFrameTime = now
|
||||
self.encodeOnQueue(pixelBuffer, time: now)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeOnQueue(_ source: CVPixelBuffer, time: Double) {
|
||||
guard let session = session ?? makeSession(), let scaled = scale(source) else { return }
|
||||
var options: CFDictionary?
|
||||
if forceKeyFrame {
|
||||
options = [kVTEncodeFrameOptionKey_ForceKeyFrame: true] as CFDictionary
|
||||
forceKeyFrame = false
|
||||
}
|
||||
busy = true
|
||||
let pts = CMTime(seconds: time, preferredTimescale: 90_000)
|
||||
let status = VTCompressionSessionEncodeFrame(session, imageBuffer: scaled, presentationTimeStamp: pts, duration: .invalid,
|
||||
frameProperties: options, infoFlagsOut: nil) { [weak self] status, _, sample in
|
||||
guard let self else { return }
|
||||
self.queue.async { self.busy = false }
|
||||
guard status == noErr, let sample else { return }
|
||||
self.emit(sample, timestamp: UInt32(truncatingIfNeeded: Int64(time * 90_000)))
|
||||
}
|
||||
if status != noErr {
|
||||
busy = false
|
||||
log.error("Preview encode failed: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSession() -> VTCompressionSession? {
|
||||
var session: VTCompressionSession?
|
||||
let status = VTCompressionSessionCreate(allocator: nil, width: width, height: height, codecType: kCMVideoCodecType_H264,
|
||||
encoderSpecification: nil, imageBufferAttributes: nil, compressedDataAllocator: nil,
|
||||
outputCallback: nil, refcon: nil, compressionSessionOut: &session)
|
||||
guard status == noErr, let session else {
|
||||
log.error("Preview encoder unavailable: \(status)")
|
||||
return nil
|
||||
}
|
||||
let properties: [CFString: Any] = [
|
||||
kVTCompressionPropertyKey_RealTime: true,
|
||||
kVTCompressionPropertyKey_ProfileLevel: kVTProfileLevel_H264_ConstrainedBaseline_AutoLevel,
|
||||
kVTCompressionPropertyKey_AllowFrameReordering: false,
|
||||
kVTCompressionPropertyKey_AverageBitRate: bitRate,
|
||||
kVTCompressionPropertyKey_ExpectedFrameRate: frameRate,
|
||||
kVTCompressionPropertyKey_MaxKeyFrameInterval: Int(frameRate * 2),
|
||||
kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration: 2,
|
||||
]
|
||||
for (key, value) in properties { VTSessionSetProperty(session, key: key, value: value as CFTypeRef) }
|
||||
VTCompressionSessionPrepareToEncodeFrames(session)
|
||||
self.session = session
|
||||
return session
|
||||
}
|
||||
|
||||
/// Scales any NDI frame (BGRA or UYVY, any size) into a pooled NV12 buffer of the preview size.
|
||||
private func scale(_ source: CVPixelBuffer) -> CVPixelBuffer? {
|
||||
if transfer == nil {
|
||||
var newTransfer: VTPixelTransferSession?
|
||||
VTPixelTransferSessionCreate(allocator: nil, pixelTransferSessionOut: &newTransfer)
|
||||
if let newTransfer {
|
||||
VTSessionSetProperty(newTransfer, key: kVTPixelTransferPropertyKey_ScalingMode, value: kVTScalingMode_Letterbox)
|
||||
}
|
||||
transfer = newTransfer
|
||||
}
|
||||
if pool == nil {
|
||||
let attributes: [CFString: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
|
||||
kCVPixelBufferWidthKey: width,
|
||||
kCVPixelBufferHeightKey: height,
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
|
||||
]
|
||||
CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &pool)
|
||||
}
|
||||
guard let transfer, let pool else { return nil }
|
||||
var target: CVPixelBuffer?
|
||||
CVPixelBufferPoolCreatePixelBuffer(nil, pool, &target)
|
||||
guard let target, VTPixelTransferSessionTransferImage(transfer, from: source, to: target) == noErr else { return nil }
|
||||
return target
|
||||
}
|
||||
|
||||
private func emit(_ sample: CMSampleBuffer, timestamp: UInt32) {
|
||||
guard let block = CMSampleBufferGetDataBuffer(sample) else { return }
|
||||
var length = 0
|
||||
var pointer: UnsafeMutablePointer<CChar>?
|
||||
guard CMBlockBufferGetDataPointer(block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length,
|
||||
dataPointerOut: &pointer) == noErr, let pointer else { return }
|
||||
let avcc = [UInt8](UnsafeRawBufferPointer(start: pointer, count: length))
|
||||
var nalUnits = H264Packetizer.nalUnits(avcc: avcc)
|
||||
|
||||
let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) as? [[CFString: Any]]
|
||||
let isKeyFrame = !(attachments?.first?[kCMSampleAttachmentKey_NotSync] as? Bool ?? false)
|
||||
if isKeyFrame, let format = CMSampleBufferGetFormatDescription(sample) {
|
||||
nalUnits = Self.parameterSets(format) + nalUnits
|
||||
}
|
||||
onAccessUnit?(nalUnits, timestamp)
|
||||
}
|
||||
|
||||
private static func parameterSets(_ format: CMFormatDescription) -> [[UInt8]] {
|
||||
var count = 0
|
||||
CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, parameterSetIndex: 0, parameterSetPointerOut: nil,
|
||||
parameterSetSizeOut: nil, parameterSetCountOut: &count, nalUnitHeaderLengthOut: nil)
|
||||
return (0..<count).compactMap { index in
|
||||
var pointer: UnsafePointer<UInt8>?
|
||||
var size = 0
|
||||
guard CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, parameterSetIndex: index, parameterSetPointerOut: &pointer,
|
||||
parameterSetSizeOut: &size, parameterSetCountOut: nil,
|
||||
nalUnitHeaderLengthOut: nil) == noErr, let pointer else { return nil }
|
||||
return Array(UnsafeBufferPointer(start: pointer, count: size))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import AudioToolbox
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Encodes mono 48 kHz Float32 into 20 ms Opus packets with AudioToolbox.
|
||||
/// Not thread-safe; the caller feeds it from one thread.
|
||||
final class TalkbackEncoder {
|
||||
static let sampleRate = 48_000.0
|
||||
static let frameSamples = 960 // 20 ms
|
||||
|
||||
private var converter: AudioConverterRef?
|
||||
private var pending: [Float] = []
|
||||
private var timestamp: UInt32 = .random(in: 0...UInt32.max)
|
||||
private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom")
|
||||
|
||||
init?(bitRate: UInt32 = 32_000) {
|
||||
var input = AudioStreamBasicDescription(
|
||||
mSampleRate: Self.sampleRate, mFormatID: kAudioFormatLinearPCM,
|
||||
mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked,
|
||||
mBytesPerPacket: 4, mFramesPerPacket: 1, mBytesPerFrame: 4, mChannelsPerFrame: 1, mBitsPerChannel: 32, mReserved: 0)
|
||||
var output = AudioStreamBasicDescription(
|
||||
mSampleRate: Self.sampleRate, mFormatID: kAudioFormatOpus, mFormatFlags: 0,
|
||||
mBytesPerPacket: 0, mFramesPerPacket: UInt32(Self.frameSamples), mBytesPerFrame: 0, mChannelsPerFrame: 1,
|
||||
mBitsPerChannel: 0, mReserved: 0)
|
||||
let status = AudioConverterNew(&input, &output, &converter)
|
||||
guard status == noErr, let converter else {
|
||||
log.error("Opus encoder unavailable: \(status)")
|
||||
return nil
|
||||
}
|
||||
var rate = bitRate
|
||||
AudioConverterSetProperty(converter, kAudioConverterEncodeBitRate, UInt32(MemoryLayout<UInt32>.size), &rate)
|
||||
}
|
||||
|
||||
deinit { if let converter { AudioConverterDispose(converter) } }
|
||||
|
||||
/// Appends mono samples at 48 kHz; returns (packet, RTP timestamp) for every full 20 ms.
|
||||
func append(_ samples: UnsafeBufferPointer<Float>) -> [([UInt8], UInt32)] {
|
||||
pending += samples
|
||||
var out: [([UInt8], UInt32)] = []
|
||||
while pending.count >= Self.frameSamples {
|
||||
let frame = Array(pending.prefix(Self.frameSamples))
|
||||
pending.removeFirst(Self.frameSamples)
|
||||
if let packet = encode(frame) { out.append((packet, timestamp)) }
|
||||
timestamp &+= UInt32(Self.frameSamples)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Input for one converter call; owns stable memory the converter may read after the callback returns.
|
||||
private final class Feed {
|
||||
let samples: UnsafeMutablePointer<Float>
|
||||
let count: Int
|
||||
var consumed = false
|
||||
init(_ frame: [Float]) {
|
||||
count = frame.count
|
||||
samples = .allocate(capacity: count)
|
||||
samples.initialize(from: frame, count: count)
|
||||
}
|
||||
deinit { samples.deallocate() }
|
||||
}
|
||||
|
||||
private func encode(_ frame: [Float]) -> [UInt8]? {
|
||||
guard let converter else { return nil }
|
||||
let feed = Feed(frame)
|
||||
var packetSize: UInt32 = 1500
|
||||
var outBytes = [UInt8](repeating: 0, count: Int(packetSize))
|
||||
var packets: UInt32 = 1
|
||||
var description = AudioStreamPacketDescription()
|
||||
let status = withExtendedLifetime(feed) { outBytes.withUnsafeMutableBytes { raw -> OSStatus in
|
||||
var list = AudioBufferList(mNumberBuffers: 1, mBuffers: AudioBuffer(mNumberChannels: 1, mDataByteSize: packetSize, mData: raw.baseAddress))
|
||||
let status = AudioConverterFillComplexBuffer(converter, { _, ioPackets, ioData, _, userData in
|
||||
let feed = Unmanaged<Feed>.fromOpaque(userData!).takeUnretainedValue()
|
||||
guard !feed.consumed else {
|
||||
ioPackets.pointee = 0
|
||||
return 1 // no more data for this call
|
||||
}
|
||||
feed.consumed = true
|
||||
ioPackets.pointee = UInt32(feed.count)
|
||||
ioData.pointee.mBuffers.mData = UnsafeMutableRawPointer(feed.samples)
|
||||
ioData.pointee.mBuffers.mDataByteSize = UInt32(feed.count * 4)
|
||||
ioData.pointee.mBuffers.mNumberChannels = 1
|
||||
return noErr
|
||||
}, Unmanaged.passUnretained(feed).toOpaque(), &packets, &list, &description)
|
||||
packetSize = list.mBuffers.mDataByteSize
|
||||
return status
|
||||
} }
|
||||
guard (status == noErr || status == 1), packets == 1, packetSize > 0 else { return nil }
|
||||
return Array(outBytes.prefix(Int(packetSize)))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
/// The page phones open from the QR code. Colors follow design/tokens.json (dark).
|
||||
enum PhonePage {
|
||||
static let html = #"""
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#14151B">
|
||||
<title>Musubi</title>
|
||||
<style>
|
||||
:root { --sumi:#14151B; --kasumi:#1E2029; --usuzumi:#2B2E3A; --washi:#ECE9E2; --nezumi:#8E8C96; --ai:#8A99F2; --moegi:#3DBE7A; --kohaku:#E8A23A; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; background: var(--sumi); color: var(--washi);
|
||||
font: 16px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
main { max-width: 720px; margin: 0 auto; padding: max(16px, env(safe-area-inset-top)) 16px 24px; }
|
||||
h1 { font-size: 20px; font-weight: 600; margin: 8px 0 2px; }
|
||||
.sub { color: var(--nezumi); margin: 0 0 16px; }
|
||||
.card { background: var(--kasumi); border: 1px solid var(--usuzumi); border-radius: 12px; padding: 16px; }
|
||||
label { display: block; color: var(--nezumi); font-size: 14px; margin: 0 0 6px; }
|
||||
input { width: 100%; font: inherit; color: var(--washi); background: var(--sumi); border: 1px solid var(--usuzumi);
|
||||
border-radius: 8px; padding: 12px; margin: 0 0 16px; }
|
||||
input:focus { outline: 2px solid var(--ai); outline-offset: -1px; }
|
||||
button { width: 100%; font: inherit; font-weight: 600; border: 0; border-radius: 8px; padding: 14px; cursor: pointer; }
|
||||
.primary { background: var(--ai); color: var(--sumi); }
|
||||
.secondary { background: var(--usuzumi); color: var(--washi); margin-top: 8px; }
|
||||
video { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 12px; display: block; }
|
||||
.status { display: flex; align-items: center; gap: 8px; color: var(--nezumi); margin: 12px 0; font-size: 14px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 4px; background: var(--usuzumi); flex: none; }
|
||||
.dot.ok { background: var(--moegi); }
|
||||
.dot.warn { background: var(--kohaku); }
|
||||
.who { color: var(--washi); }
|
||||
[hidden] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Musubi</h1>
|
||||
<p class="sub">Talkback hören, Sendung sehen.</p>
|
||||
|
||||
<form id="join" class="card">
|
||||
<label for="name">Dein Name</label>
|
||||
<input id="name" autocomplete="name" required placeholder="z. B. Anna">
|
||||
<label for="role">Deine Rolle</label>
|
||||
<input id="role" list="roles" required placeholder="z. B. Kamera 2">
|
||||
<datalist id="roles"><option>Kamera 1</option><option>Kamera 2</option><option>Kamera 3</option><option>Technik</option></datalist>
|
||||
<button class="primary" type="submit">Verbinden</button>
|
||||
</form>
|
||||
|
||||
<section id="live" hidden>
|
||||
<video id="video" playsinline autoplay></video>
|
||||
<div class="status"><span id="dot" class="dot"></span><span id="status">Verbinde …</span></div>
|
||||
<button id="unmute" class="primary" hidden>Ton einschalten</button>
|
||||
<button id="leave" class="secondary">Trennen</button>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const $ = (s) => document.querySelector(s);
|
||||
let pc = null, sessionId = null, wantConnected = false, retryTimer = null;
|
||||
|
||||
try {
|
||||
$("#name").value = localStorage.getItem("musubi.name") || "";
|
||||
$("#role").value = localStorage.getItem("musubi.role") || "";
|
||||
} catch {}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
$("#status").textContent = text;
|
||||
$("#dot").className = "dot" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
clearTimeout(retryTimer);
|
||||
const name = $("#name").value.trim(), role = $("#role").value.trim();
|
||||
setStatus("Verbinde …", "warn");
|
||||
pc = new RTCPeerConnection({ iceServers: [] });
|
||||
const current = pc;
|
||||
pc.addTransceiver("audio", { direction: "recvonly" });
|
||||
pc.addTransceiver("video", { direction: "recvonly" });
|
||||
const stream = new MediaStream();
|
||||
pc.ontrack = (e) => {
|
||||
stream.addTrack(e.track);
|
||||
const video = $("#video");
|
||||
if (video.srcObject !== stream) video.srcObject = stream;
|
||||
video.play().then(() => { $("#unmute").hidden = true; }).catch(() => { $("#unmute").hidden = false; });
|
||||
};
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (current !== pc) return;
|
||||
const state = pc.connectionState;
|
||||
if (state === "connected") setStatus("Verbunden als " + name + " · " + role, "ok");
|
||||
if (state === "failed" || state === "disconnected" || state === "closed") reconnectSoon();
|
||||
};
|
||||
try {
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
const response = await fetch("/api/offer", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, role, sdp: pc.localDescription.sdp })
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const answer = await response.json();
|
||||
sessionId = answer.id;
|
||||
await pc.setRemoteDescription({ type: "answer", sdp: answer.sdp });
|
||||
} catch (error) {
|
||||
reconnectSoon(error && error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function reconnectSoon(reason) {
|
||||
if (!wantConnected) return;
|
||||
setStatus((reason || "Verbindung weg.") + " Neuer Versuch in 3 s.", "warn");
|
||||
teardown();
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(() => { if (wantConnected) connect(); }, 3000);
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
if (sessionId) navigator.sendBeacon("/api/leave", JSON.stringify({ id: sessionId }));
|
||||
sessionId = null;
|
||||
if (pc) { pc.onconnectionstatechange = null; pc.close(); pc = null; }
|
||||
}
|
||||
|
||||
$("#join").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
localStorage.setItem("musubi.name", $("#name").value.trim());
|
||||
localStorage.setItem("musubi.role", $("#role").value.trim());
|
||||
} catch {}
|
||||
wantConnected = true;
|
||||
$("#join").hidden = true;
|
||||
$("#live").hidden = false;
|
||||
$("#video").play().catch(() => {}); // unlock playback inside the tap
|
||||
connect();
|
||||
});
|
||||
|
||||
$("#unmute").addEventListener("click", () => {
|
||||
$("#video").play().then(() => { $("#unmute").hidden = true; }).catch(() => {});
|
||||
});
|
||||
|
||||
$("#leave").addEventListener("click", () => {
|
||||
wantConnected = false;
|
||||
clearTimeout(retryTimer);
|
||||
teardown();
|
||||
$("#live").hidden = true;
|
||||
$("#join").hidden = false;
|
||||
});
|
||||
|
||||
addEventListener("pagehide", teardown);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""#
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// Big-endian byte writer for the wire formats (STUN, DTLS, RTP).
|
||||
struct ByteWriter {
|
||||
private(set) var bytes: [UInt8] = []
|
||||
|
||||
init(capacity: Int = 64) { bytes.reserveCapacity(capacity) }
|
||||
|
||||
mutating func u8(_ v: UInt8) { bytes.append(v) }
|
||||
mutating func u16(_ v: UInt16) { bytes += [UInt8(v >> 8), UInt8(v & 0xFF)] }
|
||||
mutating func u24(_ v: Int) { bytes += [UInt8((v >> 16) & 0xFF), UInt8((v >> 8) & 0xFF), UInt8(v & 0xFF)] }
|
||||
mutating func u32(_ v: UInt32) { for shift in stride(from: 24, through: 0, by: -8) { bytes.append(UInt8((v >> UInt32(shift)) & 0xFF)) } }
|
||||
mutating func u48(_ v: UInt64) { for shift in stride(from: 40, through: 0, by: -8) { bytes.append(UInt8((v >> UInt64(shift)) & 0xFF)) } }
|
||||
mutating func u64(_ v: UInt64) { for shift in stride(from: 56, through: 0, by: -8) { bytes.append(UInt8((v >> UInt64(shift)) & 0xFF)) } }
|
||||
mutating func append<C: Collection>(_ data: C) where C.Element == UInt8 { bytes += data }
|
||||
|
||||
/// Writes `body` prefixed by its length in `lengthBytes` bytes (1, 2 or 3).
|
||||
mutating func vector(_ lengthBytes: Int, _ body: (inout ByteWriter) -> Void) {
|
||||
var inner = ByteWriter()
|
||||
body(&inner)
|
||||
let n = inner.bytes.count
|
||||
switch lengthBytes {
|
||||
case 1: u8(UInt8(n))
|
||||
case 2: u16(UInt16(n))
|
||||
default: u24(n)
|
||||
}
|
||||
bytes += inner.bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// Big-endian reader that throws instead of trapping on short input.
|
||||
struct ByteReader {
|
||||
struct OutOfBounds: Error {}
|
||||
|
||||
let bytes: [UInt8]
|
||||
private(set) var offset: Int
|
||||
|
||||
init<C: Collection>(_ bytes: C, offset: Int = 0) where C.Element == UInt8 {
|
||||
self.bytes = Array(bytes)
|
||||
self.offset = offset
|
||||
}
|
||||
|
||||
var remaining: Int { bytes.count - offset }
|
||||
var isAtEnd: Bool { offset >= bytes.count }
|
||||
|
||||
mutating func u8() throws -> UInt8 {
|
||||
guard remaining >= 1 else { throw OutOfBounds() }
|
||||
defer { offset += 1 }
|
||||
return bytes[offset]
|
||||
}
|
||||
|
||||
mutating func u16() throws -> UInt16 { try UInt16(read(2)) }
|
||||
mutating func u24() throws -> Int { Int(try read(3)) }
|
||||
mutating func u32() throws -> UInt32 { try UInt32(read(4)) }
|
||||
mutating func u48() throws -> UInt64 { try read(6) }
|
||||
mutating func u64() throws -> UInt64 { try read(8) }
|
||||
|
||||
mutating func take(_ n: Int) throws -> [UInt8] {
|
||||
guard n >= 0, remaining >= n else { throw OutOfBounds() }
|
||||
defer { offset += n }
|
||||
return Array(bytes[offset..<offset + n])
|
||||
}
|
||||
|
||||
mutating func skip(_ n: Int) throws { _ = try take(n) }
|
||||
|
||||
/// Reads a length-prefixed vector (`lengthBytes` = 1, 2 or 3).
|
||||
mutating func vector(_ lengthBytes: Int) throws -> [UInt8] {
|
||||
let n: Int = switch lengthBytes {
|
||||
case 1: Int(try u8())
|
||||
case 2: Int(try u16())
|
||||
default: try u24()
|
||||
}
|
||||
return try take(n)
|
||||
}
|
||||
|
||||
private mutating func read(_ n: Int) throws -> UInt64 {
|
||||
guard remaining >= n else { throw OutOfBounds() }
|
||||
var v: UInt64 = 0
|
||||
for i in 0..<n { v = (v << 8) | UInt64(bytes[offset + i]) }
|
||||
offset += n
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == UInt8 {
|
||||
init?(hex: String) {
|
||||
let clean = hex.filter { !$0.isWhitespace && $0 != ":" }
|
||||
guard clean.count.isMultiple(of: 2) else { return nil }
|
||||
var out: [UInt8] = []
|
||||
var index = clean.startIndex
|
||||
while index < clean.endIndex {
|
||||
let next = clean.index(index, offsetBy: 2)
|
||||
guard let b = UInt8(clean[index..<next], radix: 16) else { return nil }
|
||||
out.append(b)
|
||||
index = next
|
||||
}
|
||||
self = out
|
||||
}
|
||||
|
||||
var hex: String { map { String(format: "%02x", $0) }.joined() }
|
||||
}
|
||||
|
||||
/// Compares in constant time (MACs, Finished messages).
|
||||
func constantTimeEqual(_ a: [UInt8], _ b: [UInt8]) -> Bool {
|
||||
guard a.count == b.count else { return false }
|
||||
var diff: UInt8 = 0
|
||||
for i in a.indices { diff |= a[i] ^ b[i] }
|
||||
return diff == 0
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// TLS 1.2 PRF with SHA-256 (RFC 5246 section 5).
|
||||
enum TLSPRF {
|
||||
static func compute(secret: [UInt8], label: String, seed: [UInt8], length: Int) -> [UInt8] {
|
||||
let key = SymmetricKey(data: secret)
|
||||
let fullSeed = Array(label.utf8) + seed
|
||||
var a = Array(HMAC<SHA256>.authenticationCode(for: fullSeed, using: key))
|
||||
var out: [UInt8] = []
|
||||
out.reserveCapacity(length + 32)
|
||||
while out.count < length {
|
||||
out += HMAC<SHA256>.authenticationCode(for: a + fullSeed, using: key)
|
||||
a = Array(HMAC<SHA256>.authenticationCode(for: a, using: key))
|
||||
}
|
||||
return Array(out.prefix(length))
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal DER encoding/decoding for one self-signed ECDSA certificate and for reading peer keys.
|
||||
enum DER {
|
||||
static func tlv(_ tag: UInt8, _ content: [UInt8]) -> [UInt8] {
|
||||
var out = [tag]
|
||||
let n = content.count
|
||||
if n < 0x80 {
|
||||
out.append(UInt8(n))
|
||||
} else if n <= 0xFF {
|
||||
out += [0x81, UInt8(n)]
|
||||
} else {
|
||||
out += [0x82, UInt8(n >> 8), UInt8(n & 0xFF)]
|
||||
}
|
||||
return out + content
|
||||
}
|
||||
|
||||
static func sequence(_ items: [UInt8]...) -> [UInt8] { tlv(0x30, items.flatMap { $0 }) }
|
||||
static func set(_ items: [UInt8]...) -> [UInt8] { tlv(0x31, items.flatMap { $0 }) }
|
||||
|
||||
static func integer(_ bytes: [UInt8]) -> [UInt8] {
|
||||
var b = Array(bytes.drop { $0 == 0 })
|
||||
if b.isEmpty { b = [0] }
|
||||
if b[0] & 0x80 != 0 { b.insert(0, at: 0) }
|
||||
return tlv(0x02, b)
|
||||
}
|
||||
|
||||
static func oid(_ components: [UInt]) -> [UInt8] {
|
||||
var body = [UInt8(components[0] * 40 + components[1])]
|
||||
for c in components.dropFirst(2) {
|
||||
var chunk = [UInt8(c & 0x7F)]
|
||||
var v = c >> 7
|
||||
while v > 0 {
|
||||
chunk.insert(UInt8(v & 0x7F) | 0x80, at: 0)
|
||||
v >>= 7
|
||||
}
|
||||
body += chunk
|
||||
}
|
||||
return tlv(0x06, body)
|
||||
}
|
||||
|
||||
static func utf8(_ s: String) -> [UInt8] { tlv(0x0C, Array(s.utf8)) }
|
||||
static func bitString(_ bytes: [UInt8]) -> [UInt8] { tlv(0x03, [0] + bytes) }
|
||||
|
||||
static func utcTime(_ date: Date) -> [UInt8] {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(identifier: "UTC")
|
||||
f.dateFormat = "yyMMddHHmmss'Z'"
|
||||
return tlv(0x17, Array(f.string(from: date).utf8))
|
||||
}
|
||||
|
||||
/// One parsed element: tag and the full encoding range inside the source buffer.
|
||||
struct Element {
|
||||
let tag: UInt8
|
||||
let header: Int
|
||||
let range: Range<Int>
|
||||
var contentRange: Range<Int> { range.lowerBound + header..<range.upperBound }
|
||||
}
|
||||
|
||||
static func elements(_ bytes: [UInt8], in range: Range<Int>) -> [Element]? {
|
||||
var out: [Element] = []
|
||||
var i = range.lowerBound
|
||||
while i < range.upperBound {
|
||||
guard i + 2 <= range.upperBound else { return nil }
|
||||
let tag = bytes[i]
|
||||
var length = Int(bytes[i + 1])
|
||||
var header = 2
|
||||
if length & 0x80 != 0 {
|
||||
let count = length & 0x7F
|
||||
guard count <= 3, i + 2 + count <= range.upperBound else { return nil }
|
||||
length = 0
|
||||
for k in 0..<count { length = length << 8 | Int(bytes[i + 2 + k]) }
|
||||
header += count
|
||||
}
|
||||
guard i + header + length <= range.upperBound else { return nil }
|
||||
out.append(Element(tag: tag, header: header, range: i..<i + header + length))
|
||||
i += header + length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// SubjectPublicKeyInfo of an X.509 certificate in DER.
|
||||
static func subjectPublicKeyInfo(certificate: [UInt8]) -> [UInt8]? {
|
||||
guard let cert = elements(certificate, in: 0..<certificate.count)?.first, cert.tag == 0x30,
|
||||
let tbs = elements(certificate, in: cert.contentRange)?.first, tbs.tag == 0x30,
|
||||
let fields = elements(certificate, in: tbs.contentRange) else { return nil }
|
||||
// [0] version (optional), serial, signature, issuer, validity, subject, subjectPublicKeyInfo
|
||||
let start = fields.first?.tag == 0xA0 ? 1 : 0
|
||||
guard fields.count > start + 5 else { return nil }
|
||||
return Array(certificate[fields[start + 5].range])
|
||||
}
|
||||
}
|
||||
|
||||
/// The self-signed ECDSA P-256 certificate that identifies this Musubi in DTLS.
|
||||
/// Browsers only check it against the fingerprint in the SDP, not against a CA.
|
||||
struct DTLSCertificate: Sendable {
|
||||
let privateKey: P256.Signing.PrivateKey
|
||||
let der: [UInt8]
|
||||
|
||||
/// SHA-256 fingerprint as written in SDP (`AB:CD:…`).
|
||||
var fingerprint: String { Self.fingerprint(der) }
|
||||
|
||||
static func fingerprint(_ der: [UInt8]) -> String {
|
||||
SHA256.hash(data: der).map { String(format: "%02X", $0) }.joined(separator: ":")
|
||||
}
|
||||
|
||||
init(commonName: String = "Musubi", now: Date = Date()) throws {
|
||||
let key = P256.Signing.PrivateKey()
|
||||
let ecdsaWithSHA256 = DER.sequence(DER.oid([1, 2, 840, 10045, 4, 3, 2]))
|
||||
let name = DER.sequence(DER.set(DER.sequence(DER.oid([2, 5, 4, 3]), DER.utf8(commonName))))
|
||||
let validity = DER.sequence(DER.utcTime(now.addingTimeInterval(-86_400)), DER.utcTime(now.addingTimeInterval(30 * 86_400)))
|
||||
let spki = DER.sequence(
|
||||
DER.sequence(DER.oid([1, 2, 840, 10045, 2, 1]), DER.oid([1, 2, 840, 10045, 3, 1, 7])),
|
||||
DER.bitString(Array(key.publicKey.x963Representation)))
|
||||
var serial = [UInt8](repeating: 0, count: 8)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, serial.count, &serial)
|
||||
serial[0] &= 0x7F
|
||||
let tbs = DER.sequence(
|
||||
DER.tlv(0xA0, DER.integer([2])),
|
||||
DER.integer(serial),
|
||||
ecdsaWithSHA256, name, validity, name, spki)
|
||||
let signature = try key.signature(for: tbs).derRepresentation
|
||||
self.privateKey = key
|
||||
self.der = DER.sequence(tbs, ecdsaWithSHA256, DER.bitString(Array(signature)))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,420 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Keying material for SRTP, exported from DTLS (RFC 5764 4.2).
|
||||
struct SRTPKeyMaterial: Sendable, Equatable {
|
||||
let clientKey: [UInt8]
|
||||
let serverKey: [UInt8]
|
||||
let clientSalt: [UInt8]
|
||||
let serverSalt: [UInt8]
|
||||
}
|
||||
|
||||
/// A DTLS 1.2 server for WebRTC, deliberately narrow:
|
||||
/// one cipher suite (TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), P-256, ECDSA client certificates
|
||||
/// checked against the SDP fingerprint, the use_srtp extension with SRTP_AES128_CM_HMAC_SHA1_80
|
||||
/// and extended master secret. No resumption, no renegotiation.
|
||||
/// Synchronous state machine: feed datagrams, send what comes back. Not thread-safe.
|
||||
final class DTLSServer {
|
||||
enum State: Equatable {
|
||||
case awaitingClientHello
|
||||
case awaitingClientFlight
|
||||
case established
|
||||
case closed
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
struct Failure: Error, CustomStringConvertible {
|
||||
let description: String
|
||||
let alert: UInt8
|
||||
init(_ description: String, alert: UInt8 = 40) { self.description = description; self.alert = alert }
|
||||
}
|
||||
|
||||
private enum ContentType: UInt8 { case changeCipherSpec = 20, alert = 21, handshake = 22, applicationData = 23 }
|
||||
private enum HandshakeType: UInt8 {
|
||||
case clientHello = 1, serverHello = 2, certificate = 11, serverKeyExchange = 12, certificateRequest = 13
|
||||
case serverHelloDone = 14, certificateVerify = 15, clientKeyExchange = 16, finished = 20
|
||||
}
|
||||
|
||||
static let cipherSuite: UInt16 = 0xC02B
|
||||
static let srtpProfile: UInt16 = 0x0001
|
||||
private static let version: UInt16 = 0xFEFD
|
||||
private static let ecdsaSecp256r1SHA256: UInt16 = 0x0403
|
||||
|
||||
private(set) var state: State = .awaitingClientHello
|
||||
private(set) var keyMaterial: SRTPKeyMaterial?
|
||||
|
||||
private let certificate: DTLSCertificate
|
||||
private let expectedFingerprint: String?
|
||||
|
||||
private var transcript: [UInt8] = []
|
||||
private var clientRandom: [UInt8] = []
|
||||
private var serverRandom: [UInt8] = []
|
||||
private var usesExtendedMasterSecret = false
|
||||
private var echoRenegotiationInfo = false
|
||||
private var echoPointFormats = false
|
||||
private var ephemeral = P256.KeyAgreement.PrivateKey()
|
||||
private var masterSecret: [UInt8] = []
|
||||
private var clientPublicKey: P256.Signing.PublicKey?
|
||||
|
||||
private var readCipher: (key: SymmetricKey, iv: [UInt8])?
|
||||
private var writeCipher: (key: SymmetricKey, iv: [UInt8])?
|
||||
private var writeEpoch: UInt16 = 0
|
||||
private var writeSequence: [UInt16: UInt64] = [:]
|
||||
private var nextSendMessageSeq: UInt16 = 0
|
||||
private var nextReceiveMessageSeq: UInt16 = 0
|
||||
private var fragments: [UInt16: (type: UInt8, body: [UInt8?])] = [:]
|
||||
private var lastFlight: [(type: ContentType, epoch: UInt16, body: [UInt8])] = []
|
||||
private var flightTrigger: UInt16 = 0
|
||||
|
||||
/// `expectedFingerprint`: the peer's `a=fingerprint:sha-256` from the SDP offer.
|
||||
init(certificate: DTLSCertificate, expectedFingerprint: String?) {
|
||||
self.certificate = certificate
|
||||
self.expectedFingerprint = expectedFingerprint?.uppercased()
|
||||
}
|
||||
|
||||
/// RFC 7983 demultiplexing: DTLS content types 20…63.
|
||||
static func looksLikeDTLS(_ packet: [UInt8]) -> Bool {
|
||||
guard let first = packet.first else { return false }
|
||||
return first >= 20 && first <= 63
|
||||
}
|
||||
|
||||
/// Processes one datagram and returns the datagrams to send back.
|
||||
func handle(_ datagram: [UInt8]) -> [[UInt8]] {
|
||||
switch state {
|
||||
case .closed, .failed: return []
|
||||
default: break
|
||||
}
|
||||
do {
|
||||
var output: [[UInt8]] = []
|
||||
var reader = ByteReader(datagram)
|
||||
while reader.remaining >= 13 {
|
||||
let type = try reader.u8()
|
||||
_ = try reader.u16()
|
||||
let epoch = try reader.u16()
|
||||
let sequence = try reader.u48()
|
||||
let fragment = try reader.take(Int(try reader.u16()))
|
||||
guard let content = ContentType(rawValue: type) else { continue }
|
||||
let plain: [UInt8]
|
||||
if epoch == 0 {
|
||||
plain = fragment
|
||||
} else if epoch == 1, readCipher != nil {
|
||||
guard let decrypted = decrypt(fragment, type: type, epoch: epoch, sequence: sequence) else { continue }
|
||||
plain = decrypted
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
output += try process(content, epoch: epoch, plain)
|
||||
}
|
||||
return output
|
||||
} catch let failure as Failure {
|
||||
state = .failed(failure.description)
|
||||
return [record(.alert, epoch: writeEpoch, body: [2, failure.alert])]
|
||||
} catch {
|
||||
state = .failed("Malformed DTLS record")
|
||||
return [record(.alert, epoch: writeEpoch, body: [2, 50])]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Records
|
||||
|
||||
private func process(_ type: ContentType, epoch: UInt16, _ body: [UInt8]) throws -> [[UInt8]] {
|
||||
switch type {
|
||||
case .handshake:
|
||||
return try receiveHandshake(body, epoch: epoch)
|
||||
case .changeCipherSpec:
|
||||
if state == .awaitingClientFlight, readCipher == nil, !masterSecret.isEmpty {
|
||||
readCipher = keys().client
|
||||
}
|
||||
return []
|
||||
case .alert:
|
||||
if body.count >= 2, body[1] == 0 { state = .closed } else if body.first == 2 { state = .failed("Peer alert \(body.last ?? 0)") }
|
||||
return []
|
||||
case .applicationData:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveHandshake(_ body: [UInt8], epoch: UInt16) throws -> [[UInt8]] {
|
||||
var reader = ByteReader(body)
|
||||
var output: [[UInt8]] = []
|
||||
while reader.remaining >= 12 {
|
||||
let type = try reader.u8()
|
||||
let length = try reader.u24()
|
||||
let messageSeq = try reader.u16()
|
||||
let offset = try reader.u24()
|
||||
let fragmentLength = try reader.u24()
|
||||
let fragment = try reader.take(fragmentLength)
|
||||
guard length <= 1 << 16, offset + fragmentLength <= length else { throw Failure("Bad fragment", alert: 50) }
|
||||
|
||||
if messageSeq < nextReceiveMessageSeq {
|
||||
// Retransmitted flight: the peer missed ours.
|
||||
if messageSeq == flightTrigger, !lastFlight.isEmpty { output += resendLastFlight() }
|
||||
continue
|
||||
}
|
||||
var entry = fragments[messageSeq] ?? (type, [UInt8?](repeating: nil, count: length))
|
||||
guard entry.type == type, entry.body.count == length else { throw Failure("Inconsistent fragments", alert: 50) }
|
||||
for i in 0..<fragmentLength { entry.body[offset + i] = fragment[i] }
|
||||
fragments[messageSeq] = entry
|
||||
|
||||
while let next = fragments[nextReceiveMessageSeq], !next.body.contains(where: { $0 == nil }) {
|
||||
fragments[nextReceiveMessageSeq] = nil
|
||||
let seq = nextReceiveMessageSeq
|
||||
nextReceiveMessageSeq += 1
|
||||
output += try handleMessage(next.type, next.body.map { $0! }, messageSeq: seq, epoch: epoch)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private func handleMessage(_ type: UInt8, _ body: [UInt8], messageSeq: UInt16, epoch: UInt16) throws -> [[UInt8]] {
|
||||
let full = handshakeHeader(type: type, length: body.count, messageSeq: messageSeq) + body
|
||||
switch (state, HandshakeType(rawValue: type)) {
|
||||
case (.awaitingClientHello, .clientHello):
|
||||
try parseClientHello(body)
|
||||
transcript += full
|
||||
flightTrigger = messageSeq
|
||||
return sendServerFlight()
|
||||
|
||||
case (.awaitingClientFlight, .certificate):
|
||||
var r = ByteReader(body)
|
||||
var list = ByteReader(try r.vector(3))
|
||||
guard !list.isAtEnd else { throw Failure("Client sent no certificate", alert: 42) }
|
||||
let der = try list.vector(3)
|
||||
if let expectedFingerprint, DTLSCertificate.fingerprint(der) != expectedFingerprint {
|
||||
throw Failure("Client certificate does not match the SDP fingerprint", alert: 42)
|
||||
}
|
||||
guard let spki = DER.subjectPublicKeyInfo(certificate: der),
|
||||
let key = try? P256.Signing.PublicKey(derRepresentation: spki) else {
|
||||
throw Failure("Unsupported client certificate", alert: 43)
|
||||
}
|
||||
clientPublicKey = key
|
||||
transcript += full
|
||||
|
||||
case (.awaitingClientFlight, .clientKeyExchange):
|
||||
var r = ByteReader(body)
|
||||
let point = try r.vector(1)
|
||||
guard let peer = try? P256.KeyAgreement.PublicKey(x963Representation: point),
|
||||
let shared = try? ephemeral.sharedSecretFromKeyAgreement(with: peer) else {
|
||||
throw Failure("Bad client key share", alert: 47)
|
||||
}
|
||||
let preMaster = shared.withUnsafeBytes { Array($0) }
|
||||
transcript += full
|
||||
if usesExtendedMasterSecret {
|
||||
masterSecret = TLSPRF.compute(secret: preMaster, label: "extended master secret",
|
||||
seed: Array(SHA256.hash(data: transcript)), length: 48)
|
||||
} else {
|
||||
masterSecret = TLSPRF.compute(secret: preMaster, label: "master secret",
|
||||
seed: clientRandom + serverRandom, length: 48)
|
||||
}
|
||||
|
||||
case (.awaitingClientFlight, .certificateVerify):
|
||||
guard let clientPublicKey, !masterSecret.isEmpty else { throw Failure("Unexpected CertificateVerify", alert: 10) }
|
||||
var r = ByteReader(body)
|
||||
guard try r.u16() == Self.ecdsaSecp256r1SHA256 else { throw Failure("Unsupported signature algorithm", alert: 40) }
|
||||
let signature = try r.vector(2)
|
||||
guard let parsed = try? P256.Signing.ECDSASignature(derRepresentation: signature),
|
||||
clientPublicKey.isValidSignature(parsed, for: transcript) else {
|
||||
throw Failure("CertificateVerify failed", alert: 51)
|
||||
}
|
||||
transcript += full
|
||||
|
||||
case (.awaitingClientFlight, .finished):
|
||||
guard epoch == 1, clientPublicKey != nil else { throw Failure("Finished before authentication", alert: 10) }
|
||||
let expected = TLSPRF.compute(secret: masterSecret, label: "client finished",
|
||||
seed: Array(SHA256.hash(data: transcript)), length: 12)
|
||||
guard constantTimeEqual(body, expected) else { throw Failure("Client Finished does not verify", alert: 51) }
|
||||
transcript += full
|
||||
flightTrigger = messageSeq
|
||||
let output = sendFinalFlight()
|
||||
exportKeys()
|
||||
state = .established
|
||||
return output
|
||||
|
||||
default:
|
||||
throw Failure("Unexpected handshake message \(type)", alert: 10)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// MARK: - ClientHello
|
||||
|
||||
private func parseClientHello(_ body: [UInt8]) throws {
|
||||
var r = ByteReader(body)
|
||||
guard try r.u16() == Self.version else { throw Failure("Only DTLS 1.2 is supported", alert: 70) }
|
||||
clientRandom = try r.take(32)
|
||||
_ = try r.vector(1) // session id
|
||||
_ = try r.vector(1) // cookie
|
||||
var suites = ByteReader(try r.vector(2))
|
||||
var offersSuite = false
|
||||
while suites.remaining >= 2 {
|
||||
let suite = try suites.u16()
|
||||
if suite == Self.cipherSuite { offersSuite = true }
|
||||
if suite == 0x00FF { echoRenegotiationInfo = true }
|
||||
}
|
||||
guard offersSuite else { throw Failure("Client does not offer ECDHE-ECDSA-AES128-GCM-SHA256") }
|
||||
_ = try r.vector(1) // compression methods
|
||||
|
||||
var offersSRTP = false
|
||||
var offersP256 = false
|
||||
if !r.isAtEnd {
|
||||
var extensions = ByteReader(try r.vector(2))
|
||||
while extensions.remaining >= 4 {
|
||||
let type = try extensions.u16()
|
||||
var data = ByteReader(try extensions.vector(2))
|
||||
switch type {
|
||||
case 0x000E: // use_srtp
|
||||
var profiles = ByteReader(try data.vector(2))
|
||||
while profiles.remaining >= 2 { if try profiles.u16() == Self.srtpProfile { offersSRTP = true } }
|
||||
case 0x000A: // supported_groups
|
||||
var groups = ByteReader(try data.vector(2))
|
||||
while groups.remaining >= 2 { if try groups.u16() == 23 { offersP256 = true } }
|
||||
case 0x0017: usesExtendedMasterSecret = true
|
||||
case 0xFF01: echoRenegotiationInfo = true
|
||||
case 0x000B: echoPointFormats = true
|
||||
default: break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
offersP256 = true // no supported_groups extension means "any"
|
||||
}
|
||||
guard offersSRTP else { throw Failure("Client does not offer SRTP_AES128_CM_HMAC_SHA1_80") }
|
||||
guard offersP256 else { throw Failure("Client does not offer P-256") }
|
||||
}
|
||||
|
||||
// MARK: - Server flights
|
||||
|
||||
private func sendServerFlight() -> [[UInt8]] {
|
||||
serverRandom = (0..<32).map { _ in UInt8.random(in: 0...255) }
|
||||
|
||||
var hello = ByteWriter()
|
||||
hello.u16(Self.version)
|
||||
hello.append(serverRandom)
|
||||
hello.u8(0) // session id
|
||||
hello.u16(Self.cipherSuite)
|
||||
hello.u8(0) // no compression
|
||||
hello.vector(2) { ext in
|
||||
if echoRenegotiationInfo { ext.u16(0xFF01); ext.u16(1); ext.u8(0) }
|
||||
if usesExtendedMasterSecret { ext.u16(0x0017); ext.u16(0) }
|
||||
if echoPointFormats { ext.u16(0x000B); ext.u16(2); ext.u8(1); ext.u8(0) }
|
||||
ext.u16(0x000E); ext.u16(5); ext.u16(2); ext.u16(Self.srtpProfile); ext.u8(0)
|
||||
}
|
||||
|
||||
var cert = ByteWriter()
|
||||
cert.vector(3) { list in list.vector(3) { $0.append(certificate.der) } }
|
||||
|
||||
var params = ByteWriter()
|
||||
params.u8(3) // named_curve
|
||||
params.u16(23) // secp256r1
|
||||
params.vector(1) { $0.append(ephemeral.publicKey.x963Representation) }
|
||||
let signed = (try? certificate.privateKey.signature(for: clientRandom + serverRandom + params.bytes).derRepresentation) ?? Data()
|
||||
var keyExchange = params
|
||||
keyExchange.u16(Self.ecdsaSecp256r1SHA256)
|
||||
keyExchange.vector(2) { $0.append(signed) }
|
||||
|
||||
var request = ByteWriter()
|
||||
request.vector(1) { $0.u8(64) } // ecdsa_sign
|
||||
request.vector(2) { $0.u16(Self.ecdsaSecp256r1SHA256) }
|
||||
request.u16(0) // no CA names
|
||||
|
||||
lastFlight = []
|
||||
for (type, body) in [(HandshakeType.serverHello, hello.bytes), (.certificate, cert.bytes),
|
||||
(.serverKeyExchange, keyExchange.bytes), (.certificateRequest, request.bytes),
|
||||
(.serverHelloDone, [])] {
|
||||
let message = handshakeHeader(type: type.rawValue, length: body.count, messageSeq: nextSendMessageSeq) + body
|
||||
nextSendMessageSeq += 1
|
||||
transcript += message
|
||||
lastFlight.append((.handshake, 0, message))
|
||||
}
|
||||
state = .awaitingClientFlight
|
||||
return resendLastFlight()
|
||||
}
|
||||
|
||||
private func sendFinalFlight() -> [[UInt8]] {
|
||||
let verify = TLSPRF.compute(secret: masterSecret, label: "server finished",
|
||||
seed: Array(SHA256.hash(data: transcript)), length: 12)
|
||||
let finished = handshakeHeader(type: HandshakeType.finished.rawValue, length: 12, messageSeq: nextSendMessageSeq) + verify
|
||||
nextSendMessageSeq += 1
|
||||
transcript += finished
|
||||
writeCipher = keys().server
|
||||
lastFlight = [(.changeCipherSpec, 0, [1]), (.handshake, 1, finished)]
|
||||
return resendLastFlight()
|
||||
}
|
||||
|
||||
/// Sends the last flight again with fresh record sequence numbers, all in one datagram.
|
||||
private func resendLastFlight() -> [[UInt8]] {
|
||||
[lastFlight.flatMap { record($0.type, epoch: $0.epoch, body: $0.body) }]
|
||||
}
|
||||
|
||||
private func handshakeHeader(type: UInt8, length: Int, messageSeq: UInt16) -> [UInt8] {
|
||||
var w = ByteWriter(capacity: 12)
|
||||
w.u8(type)
|
||||
w.u24(length)
|
||||
w.u16(messageSeq)
|
||||
w.u24(0)
|
||||
w.u24(length)
|
||||
return w.bytes
|
||||
}
|
||||
|
||||
private func record(_ type: ContentType, epoch: UInt16, body: [UInt8]) -> [UInt8] {
|
||||
let sequence = writeSequence[epoch, default: 0]
|
||||
writeSequence[epoch] = sequence + 1
|
||||
let payload = epoch == 0 ? body : encrypt(body, type: type.rawValue, epoch: epoch, sequence: sequence)
|
||||
var w = ByteWriter(capacity: 13 + payload.count)
|
||||
w.u8(type.rawValue)
|
||||
w.u16(Self.version)
|
||||
w.u16(epoch)
|
||||
w.u48(sequence)
|
||||
w.u16(UInt16(payload.count))
|
||||
w.append(payload)
|
||||
return w.bytes
|
||||
}
|
||||
|
||||
// MARK: - Keys and AEAD
|
||||
|
||||
private func keys() -> (client: (key: SymmetricKey, iv: [UInt8]), server: (key: SymmetricKey, iv: [UInt8])) {
|
||||
let block = TLSPRF.compute(secret: masterSecret, label: "key expansion", seed: serverRandom + clientRandom, length: 40)
|
||||
return ((SymmetricKey(data: block[0..<16]), Array(block[32..<36])),
|
||||
(SymmetricKey(data: block[16..<32]), Array(block[36..<40])))
|
||||
}
|
||||
|
||||
private func exportKeys() {
|
||||
let m = TLSPRF.compute(secret: masterSecret, label: "EXTRACTOR-dtls_srtp", seed: clientRandom + serverRandom, length: 60)
|
||||
keyMaterial = SRTPKeyMaterial(clientKey: Array(m[0..<16]), serverKey: Array(m[16..<32]),
|
||||
clientSalt: Array(m[32..<46]), serverSalt: Array(m[46..<60]))
|
||||
}
|
||||
|
||||
private func additionalData(type: UInt8, epoch: UInt16, sequence: UInt64, length: Int) -> [UInt8] {
|
||||
var w = ByteWriter(capacity: 13)
|
||||
w.u16(epoch)
|
||||
w.u48(sequence)
|
||||
w.u8(type)
|
||||
w.u16(Self.version)
|
||||
w.u16(UInt16(length))
|
||||
return w.bytes
|
||||
}
|
||||
|
||||
private func encrypt(_ plain: [UInt8], type: UInt8, epoch: UInt16, sequence: UInt64) -> [UInt8] {
|
||||
guard let writeCipher else { return plain }
|
||||
var explicit = ByteWriter(capacity: 8)
|
||||
explicit.u16(epoch)
|
||||
explicit.u48(sequence)
|
||||
guard let nonce = try? AES.GCM.Nonce(data: writeCipher.iv + explicit.bytes),
|
||||
let sealed = try? AES.GCM.seal(plain, using: writeCipher.key, nonce: nonce,
|
||||
authenticating: additionalData(type: type, epoch: epoch, sequence: sequence, length: plain.count))
|
||||
else { return [] }
|
||||
return explicit.bytes + Array(sealed.ciphertext) + Array(sealed.tag)
|
||||
}
|
||||
|
||||
private func decrypt(_ fragment: [UInt8], type: UInt8, epoch: UInt16, sequence: UInt64) -> [UInt8]? {
|
||||
guard let readCipher, fragment.count >= 8 + 16 else { return nil }
|
||||
let explicit = Array(fragment[0..<8])
|
||||
let ciphertext = Array(fragment[8..<fragment.count - 16])
|
||||
let tag = Array(fragment[(fragment.count - 16)...])
|
||||
guard let nonce = try? AES.GCM.Nonce(data: readCipher.iv + explicit),
|
||||
let box = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag),
|
||||
let plain = try? AES.GCM.open(box, using: readCipher.key,
|
||||
authenticating: additionalData(type: type, epoch: epoch, sequence: sequence, length: ciphertext.count))
|
||||
else { return nil }
|
||||
return Array(plain)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
import Foundation
|
||||
|
||||
/// One phone: ICE-lite credentials, DTLS, SRTP and the two outgoing RTP streams.
|
||||
/// Owned and called by `IntercomEngine` on its queue only.
|
||||
final class PeerSession {
|
||||
struct Offer {
|
||||
let answer: String
|
||||
let session: PeerSession
|
||||
}
|
||||
|
||||
enum Kind { case stun, dtls, rtcp, other }
|
||||
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let role: String
|
||||
let localUfrag: String
|
||||
let localPassword: String
|
||||
let remoteUfrag: String
|
||||
let created = Date()
|
||||
|
||||
private(set) var lastConsent: Date?
|
||||
private(set) var connectedSince: Date?
|
||||
private var dtls: DTLSServer
|
||||
private var srtp: SRTPContext?
|
||||
private var audio: RTPStream?
|
||||
private var video: RTPStream?
|
||||
private var lastAudioTimestamp: UInt32 = 0
|
||||
private var lastVideoTimestamp: UInt32 = 0
|
||||
private var reportsDue = Date()
|
||||
|
||||
var isMediaReady: Bool { srtp != nil }
|
||||
var dtlsState: DTLSServer.State { dtls.state }
|
||||
|
||||
private init(name: String, role: String, remoteUfrag: String, certificate: DTLSCertificate, remoteFingerprint: String) {
|
||||
self.name = name
|
||||
self.role = role
|
||||
self.remoteUfrag = remoteUfrag
|
||||
localUfrag = Self.token(8)
|
||||
localPassword = Self.token(24)
|
||||
dtls = DTLSServer(certificate: certificate, expectedFingerprint: remoteFingerprint)
|
||||
}
|
||||
|
||||
/// Accepts a browser offer and returns the answer; nil if the offer has nothing Musubi can send.
|
||||
static func accept(offerSDP: String, name: String, role: String, certificate: DTLSCertificate,
|
||||
address: String, port: UInt16) throws -> Offer? {
|
||||
let offer = try SDPOffer(offerSDP)
|
||||
let session = PeerSession(name: name, role: role, remoteUfrag: offer.iceUfrag, certificate: certificate,
|
||||
remoteFingerprint: offer.fingerprint)
|
||||
var tracks: [SDPAnswer.Track] = []
|
||||
for media in offer.media {
|
||||
let ssrc = UInt32.random(in: 1...UInt32.max)
|
||||
var payloadType: Int?
|
||||
if media.port != 0 {
|
||||
if media.kind == "audio", session.audio == nil, let pt = offer.opusPayloadType(in: media) {
|
||||
payloadType = pt
|
||||
session.audio = RTPStream(ssrc: ssrc, payloadType: UInt8(pt))
|
||||
} else if media.kind == "video", session.video == nil, let pt = offer.h264PayloadType(in: media) {
|
||||
payloadType = pt
|
||||
session.video = RTPStream(ssrc: ssrc, payloadType: UInt8(pt))
|
||||
}
|
||||
}
|
||||
tracks.append(.init(mid: media.mid, kind: media.kind, payloadType: payloadType, ssrc: ssrc))
|
||||
}
|
||||
guard session.audio != nil || session.video != nil else { return nil }
|
||||
let answer = SDPAnswer(iceUfrag: session.localUfrag, icePwd: session.localPassword,
|
||||
fingerprint: certificate.fingerprint, address: address, port: port, tracks: tracks)
|
||||
return Offer(answer: answer.text, session: session)
|
||||
}
|
||||
|
||||
static func classify(_ packet: [UInt8]) -> Kind {
|
||||
if STUN.looksLikeSTUN(packet) { return .stun }
|
||||
if DTLSServer.looksLikeDTLS(packet) { return .dtls }
|
||||
if RTCPFeedback.isRTCP(packet) { return .rtcp }
|
||||
return .other
|
||||
}
|
||||
|
||||
/// Answers a connectivity check if it carries this session's credentials.
|
||||
func answerSTUN(_ packet: [UInt8], message: STUN.Message, address: [UInt8], port: UInt16) -> [UInt8]? {
|
||||
guard message.type == STUN.bindingRequest, message.username == "\(localUfrag):\(remoteUfrag)",
|
||||
STUN.verify(packet, message: message, key: Array(localPassword.utf8)) else { return nil }
|
||||
lastConsent = Date()
|
||||
return STUN.bindingResponse(transactionID: message.transactionID, address: address, port: port,
|
||||
key: Array(localPassword.utf8))
|
||||
}
|
||||
|
||||
/// Feeds DTLS; returns datagrams to send and whether media just became ready.
|
||||
func receiveDTLS(_ packet: [UInt8]) -> (replies: [[UInt8]], becameReady: Bool) {
|
||||
let replies = dtls.handle(packet)
|
||||
guard srtp == nil, let keys = dtls.keyMaterial else { return (replies, false) }
|
||||
// DTLS server = answerer: we send with the server keys.
|
||||
srtp = SRTPContext(masterKey: keys.serverKey, masterSalt: keys.serverSalt)
|
||||
connectedSince = Date()
|
||||
return (replies, true)
|
||||
}
|
||||
|
||||
/// True if the browser asks for a key frame.
|
||||
func receiveRTCP(_ packet: [UInt8]) -> Bool {
|
||||
guard let srtpIn = inboundRTCP, let plain = srtpIn.unprotectRTCP(packet) else { return false }
|
||||
return RTCPFeedback.requestsKeyFrame(plain)
|
||||
}
|
||||
|
||||
private lazy var inboundRTCP: SRTPContext? = {
|
||||
guard let keys = dtls.keyMaterial else { return nil }
|
||||
return SRTPContext(masterKey: keys.clientKey, masterSalt: keys.clientSalt)
|
||||
}()
|
||||
|
||||
func audioPacket(opus: [UInt8], timestamp: UInt32) -> [UInt8]? {
|
||||
guard let srtp, audio != nil else { return nil }
|
||||
lastAudioTimestamp = timestamp
|
||||
return srtp.protectRTP(audio!.packet(timestamp: timestamp, marker: false, payload: opus))
|
||||
}
|
||||
|
||||
func videoPackets(payloads: [[UInt8]], timestamp: UInt32) -> [[UInt8]] {
|
||||
guard let srtp, video != nil else { return [] }
|
||||
lastVideoTimestamp = timestamp
|
||||
return payloads.enumerated().map { index, payload in
|
||||
srtp.protectRTP(video!.packet(timestamp: timestamp, marker: index == payloads.count - 1, payload: payload))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sender reports roughly once per second.
|
||||
func senderReports(now: Date = Date()) -> [[UInt8]] {
|
||||
guard let srtp, now >= reportsDue else { return [] }
|
||||
reportsDue = now.addingTimeInterval(1)
|
||||
var out: [[UInt8]] = []
|
||||
if let audio {
|
||||
out.append(srtp.protectRTCP(RTCPFeedback.senderReport(ssrc: audio.ssrc, rtpTimestamp: lastAudioTimestamp,
|
||||
packets: audio.packetCount, octets: audio.octetCount, now: now)))
|
||||
}
|
||||
if let video {
|
||||
out.append(srtp.protectRTCP(RTCPFeedback.senderReport(ssrc: video.ssrc, rtpTimestamp: lastVideoTimestamp,
|
||||
packets: video.packetCount, octets: video.octetCount, now: now)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// No consent for 30 s (RFC 7675), or never connected within 30 s, or DTLS ended.
|
||||
func isExpired(now: Date = Date()) -> Bool {
|
||||
switch dtls.state {
|
||||
case .closed, .failed: return true
|
||||
default: break
|
||||
}
|
||||
return now.timeIntervalSince(lastConsent ?? created) > 30
|
||||
}
|
||||
|
||||
private static func token(_ length: Int) -> String {
|
||||
let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||
return String((0..<length).map { _ in alphabet.randomElement()! })
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
/// Writes RTP packets for one outgoing stream (RFC 3550).
|
||||
struct RTPStream {
|
||||
let ssrc: UInt32
|
||||
let payloadType: UInt8
|
||||
private(set) var sequence: UInt16 = .random(in: 0...UInt16.max)
|
||||
private(set) var packetCount: UInt32 = 0
|
||||
private(set) var octetCount: UInt32 = 0
|
||||
|
||||
init(ssrc: UInt32, payloadType: UInt8) {
|
||||
self.ssrc = ssrc
|
||||
self.payloadType = payloadType
|
||||
}
|
||||
|
||||
mutating func packet(timestamp: UInt32, marker: Bool, payload: [UInt8]) -> [UInt8] {
|
||||
var w = ByteWriter(capacity: 12 + payload.count)
|
||||
w.u8(0x80)
|
||||
w.u8((marker ? 0x80 : 0) | (payloadType & 0x7F))
|
||||
w.u16(sequence)
|
||||
w.u32(timestamp)
|
||||
w.u32(ssrc)
|
||||
w.append(payload)
|
||||
sequence &+= 1
|
||||
packetCount &+= 1
|
||||
octetCount &+= UInt32(payload.count)
|
||||
return w.bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// H.264 packetization (RFC 6184, packetization-mode 1): single NAL units, STAP-A and FU-A.
|
||||
enum H264Packetizer {
|
||||
/// Largest RTP payload; keeps IP packets well below 1500 bytes after SRTP and IPv6 headers.
|
||||
static let maxPayload = 1100
|
||||
|
||||
/// Splits one access unit (NAL units without start codes or length prefixes) into RTP payloads.
|
||||
/// Small leading parameter sets (SPS/PPS) are aggregated into one STAP-A.
|
||||
static func payloads(for nalUnits: [[UInt8]]) -> [[UInt8]] {
|
||||
var out: [[UInt8]] = []
|
||||
var aggregate: [[UInt8]] = []
|
||||
|
||||
func flushAggregate() {
|
||||
if aggregate.count == 1 {
|
||||
out.append(aggregate[0])
|
||||
} else if aggregate.count > 1 {
|
||||
var w = ByteWriter()
|
||||
w.u8((aggregate.map { $0[0] & 0x60 }.max() ?? 0) | 24) // STAP-A, highest NRI
|
||||
for nal in aggregate { w.u16(UInt16(nal.count)); w.append(nal) }
|
||||
out.append(w.bytes)
|
||||
}
|
||||
aggregate = []
|
||||
}
|
||||
|
||||
for nal in nalUnits where !nal.isEmpty {
|
||||
let type = nal[0] & 0x1F
|
||||
if type == 7 || type == 8 {
|
||||
let size = aggregate.reduce(1) { $0 + 2 + $1.count } + 2 + nal.count
|
||||
if size > maxPayload { flushAggregate() }
|
||||
aggregate.append(nal)
|
||||
continue
|
||||
}
|
||||
flushAggregate()
|
||||
if nal.count <= maxPayload {
|
||||
out.append(nal)
|
||||
continue
|
||||
}
|
||||
let indicator = (nal[0] & 0xE0) | 28 // FU-A
|
||||
var offset = 1
|
||||
while offset < nal.count {
|
||||
let end = min(offset + maxPayload - 2, nal.count)
|
||||
var header = type
|
||||
if offset == 1 { header |= 0x80 }
|
||||
if end == nal.count { header |= 0x40 }
|
||||
out.append([indicator, header] + nal[offset..<end])
|
||||
offset = end
|
||||
}
|
||||
}
|
||||
flushAggregate()
|
||||
return out
|
||||
}
|
||||
|
||||
/// Splits an AVCC buffer (4-byte big-endian length prefixes, as from VideoToolbox) into NAL units.
|
||||
static func nalUnits(avcc: [UInt8]) -> [[UInt8]] {
|
||||
var out: [[UInt8]] = []
|
||||
var i = 0
|
||||
while i + 4 <= avcc.count {
|
||||
let length = Int(avcc[i]) << 24 | Int(avcc[i + 1]) << 16 | Int(avcc[i + 2]) << 8 | Int(avcc[i + 3])
|
||||
i += 4
|
||||
guard length > 0, i + length <= avcc.count else { break }
|
||||
out.append(Array(avcc[i..<i + length]))
|
||||
i += length
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/// RTCP feedback the browser sends that the sender must act on.
|
||||
enum RTCPFeedback {
|
||||
/// True if a (compound) RTCP packet asks for a key frame (PLI or FIR).
|
||||
static func requestsKeyFrame(_ packet: [UInt8]) -> Bool {
|
||||
var i = 0
|
||||
while i + 4 <= packet.count {
|
||||
let fmt = packet[i] & 0x1F
|
||||
let type = packet[i + 1]
|
||||
let length = ((Int(packet[i + 2]) << 8 | Int(packet[i + 3])) + 1) * 4
|
||||
if (type == 206 && fmt == 1) || (type == 206 && fmt == 4) { return true }
|
||||
guard length > 0 else { return false }
|
||||
i += length
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// RFC 7983: RTP/RTCP start with version 2 (first byte 128…191); RTCP payload types are 192…223.
|
||||
static func isRTCP(_ packet: [UInt8]) -> Bool {
|
||||
packet.count >= 8 && packet[0] >= 128 && packet[0] <= 191 && packet[1] >= 192 && packet[1] <= 223
|
||||
}
|
||||
|
||||
/// Sender report so the browser can map RTP timestamps to wall clock (RFC 3550 6.4.1).
|
||||
static func senderReport(ssrc: UInt32, rtpTimestamp: UInt32, packets: UInt32, octets: UInt32, now: Date = Date()) -> [UInt8] {
|
||||
let ntp = now.timeIntervalSince1970 + 2_208_988_800
|
||||
let seconds = UInt32(ntp)
|
||||
let fraction = UInt32((ntp - Double(seconds)) * 4_294_967_296)
|
||||
var w = ByteWriter(capacity: 28)
|
||||
w.u8(0x80)
|
||||
w.u8(200)
|
||||
w.u16(6)
|
||||
w.u32(ssrc)
|
||||
w.u32(seconds)
|
||||
w.u32(fraction)
|
||||
w.u32(rtpTimestamp)
|
||||
w.u32(packets)
|
||||
w.u32(octets)
|
||||
return w.bytes
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
|
||||
/// What Musubi needs from a browser's SDP offer (Unified Plan, BUNDLE, rtcp-mux).
|
||||
struct SDPOffer: Equatable {
|
||||
struct Media: Equatable {
|
||||
var kind: String
|
||||
var mid: String
|
||||
var port: Int
|
||||
var payloadTypes: [Int]
|
||||
var rtpmap: [Int: String] = [:]
|
||||
var fmtp: [Int: String] = [:]
|
||||
}
|
||||
|
||||
var iceUfrag: String
|
||||
var icePwd: String
|
||||
/// `sha-256` fingerprint, uppercase hex with colons.
|
||||
var fingerprint: String
|
||||
var media: [Media]
|
||||
|
||||
enum ParseError: Error, Equatable { case missing(String) }
|
||||
|
||||
init(_ sdp: String) throws {
|
||||
var sessionUfrag: String?, sessionPwd: String?, sessionFingerprint: String?
|
||||
var mediaUfrag: String?, mediaPwd: String?, mediaFingerprint: String?
|
||||
var media: [Media] = []
|
||||
|
||||
for raw in sdp.split(whereSeparator: \.isNewline) {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
if line.hasPrefix("m=") {
|
||||
let parts = line.dropFirst(2).split(separator: " ")
|
||||
guard parts.count >= 3 else { continue }
|
||||
media.append(Media(kind: String(parts[0]), mid: "\(media.count)", port: Int(parts[1]) ?? 0,
|
||||
payloadTypes: parts.dropFirst(3).compactMap { Int($0) }))
|
||||
continue
|
||||
}
|
||||
guard line.hasPrefix("a=") else { continue }
|
||||
let attribute = line.dropFirst(2)
|
||||
let (name, value) = attribute.firstIndex(of: ":").map {
|
||||
(String(attribute[..<$0]), String(attribute[attribute.index(after: $0)...]))
|
||||
} ?? (String(attribute), "")
|
||||
let inMedia = !media.isEmpty
|
||||
|
||||
switch name {
|
||||
case "ice-ufrag": if inMedia { mediaUfrag = mediaUfrag ?? value } else { sessionUfrag = value }
|
||||
case "ice-pwd": if inMedia { mediaPwd = mediaPwd ?? value } else { sessionPwd = value }
|
||||
case "fingerprint":
|
||||
let parts = value.split(separator: " ")
|
||||
guard parts.count == 2, parts[0].lowercased() == "sha-256" else { continue }
|
||||
if inMedia { mediaFingerprint = mediaFingerprint ?? parts[1].uppercased() } else { sessionFingerprint = parts[1].uppercased() }
|
||||
case "mid" where inMedia:
|
||||
media[media.count - 1].mid = value
|
||||
case "rtpmap" where inMedia, "fmtp" where inMedia:
|
||||
guard let space = value.firstIndex(of: " "), let pt = Int(value[..<space]) else { continue }
|
||||
let rest = String(value[value.index(after: space)...])
|
||||
if name == "rtpmap" { media[media.count - 1].rtpmap[pt] = rest } else { media[media.count - 1].fmtp[pt] = rest }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
guard let ufrag = sessionUfrag ?? mediaUfrag else { throw ParseError.missing("ice-ufrag") }
|
||||
guard let pwd = sessionPwd ?? mediaPwd else { throw ParseError.missing("ice-pwd") }
|
||||
guard let fingerprint = sessionFingerprint ?? mediaFingerprint else { throw ParseError.missing("fingerprint") }
|
||||
iceUfrag = ufrag
|
||||
icePwd = pwd
|
||||
self.fingerprint = fingerprint
|
||||
self.media = media
|
||||
}
|
||||
|
||||
/// Payload type for Opus at 48 kHz.
|
||||
func opusPayloadType(in media: Media) -> Int? {
|
||||
media.payloadTypes.first { media.rtpmap[$0]?.lowercased().hasPrefix("opus/48000") == true }
|
||||
}
|
||||
|
||||
/// Payload type for H.264 with packetization-mode=1 in (constrained) baseline profile.
|
||||
func h264PayloadType(in media: Media) -> Int? {
|
||||
let candidates = media.payloadTypes.filter { pt in
|
||||
guard media.rtpmap[pt]?.uppercased().hasPrefix("H264/90000") == true, let fmtp = media.fmtp[pt]?.lowercased() else { return false }
|
||||
return fmtp.contains("packetization-mode=1")
|
||||
}
|
||||
func profile(_ pt: Int) -> String {
|
||||
media.fmtp[pt]?.lowercased().split(separator: ";")
|
||||
.first { $0.trimmingCharacters(in: .whitespaces).hasPrefix("profile-level-id=") }
|
||||
.map { String($0.split(separator: "=").last ?? "") } ?? ""
|
||||
}
|
||||
return candidates.first { profile($0).hasPrefix("42e0") } ?? candidates.first { profile($0).hasPrefix("4200") }
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the ICE-lite, DTLS-passive, send-only answer.
|
||||
struct SDPAnswer {
|
||||
struct Track {
|
||||
var mid: String
|
||||
var kind: String
|
||||
/// nil rejects the section (port 0).
|
||||
var payloadType: Int?
|
||||
var ssrc: UInt32
|
||||
}
|
||||
|
||||
var iceUfrag: String
|
||||
var icePwd: String
|
||||
var fingerprint: String
|
||||
var address: String
|
||||
var port: UInt16
|
||||
var tracks: [Track]
|
||||
|
||||
var text: String {
|
||||
let accepted = tracks.filter { $0.payloadType != nil }
|
||||
var lines = [
|
||||
"v=0",
|
||||
"o=- \(UInt32.random(in: 1...UInt32.max)) 2 IN IP4 \(address)",
|
||||
"s=Musubi",
|
||||
"t=0 0",
|
||||
"a=ice-lite",
|
||||
"a=group:BUNDLE \(accepted.map(\.mid).joined(separator: " "))",
|
||||
"a=msid-semantic: WMS musubi",
|
||||
]
|
||||
for track in tracks {
|
||||
guard let pt = track.payloadType else {
|
||||
lines += ["m=\(track.kind) 0 UDP/TLS/RTP/SAVPF 0", "c=IN IP4 0.0.0.0", "a=mid:\(track.mid)", "a=inactive"]
|
||||
continue
|
||||
}
|
||||
lines += [
|
||||
"m=\(track.kind) \(port) UDP/TLS/RTP/SAVPF \(pt)",
|
||||
"c=IN IP4 \(address)",
|
||||
"a=rtcp:\(port) IN IP4 \(address)",
|
||||
"a=ice-ufrag:\(iceUfrag)",
|
||||
"a=ice-pwd:\(icePwd)",
|
||||
"a=fingerprint:sha-256 \(fingerprint)",
|
||||
"a=setup:passive",
|
||||
"a=mid:\(track.mid)",
|
||||
"a=sendonly",
|
||||
"a=rtcp-mux",
|
||||
"a=msid:musubi \(track.kind)",
|
||||
]
|
||||
if track.kind == "audio" {
|
||||
lines += ["a=rtpmap:\(pt) opus/48000/2", "a=fmtp:\(pt) minptime=10;useinbandfec=1"]
|
||||
} else {
|
||||
lines += [
|
||||
"a=rtpmap:\(pt) H264/90000",
|
||||
"a=rtcp-fb:\(pt) nack pli",
|
||||
"a=rtcp-fb:\(pt) ccm fir",
|
||||
"a=fmtp:\(pt) level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
||||
]
|
||||
}
|
||||
lines += [
|
||||
"a=ssrc:\(track.ssrc) cname:musubi",
|
||||
"a=ssrc:\(track.ssrc) msid:musubi \(track.kind)",
|
||||
"a=candidate:1 1 udp 2130706431 \(address) \(port) typ host",
|
||||
"a=end-of-candidates",
|
||||
]
|
||||
}
|
||||
return lines.joined(separator: "\r\n") + "\r\n"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
import CommonCrypto
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// AES-128 in ECB mode, used as the block function for counter mode (CryptoKit has no AES-CTR).
|
||||
final class AESBlock: @unchecked Sendable {
|
||||
private var cryptor: CCCryptorRef?
|
||||
|
||||
init(key: [UInt8]) {
|
||||
precondition(key.count == 16)
|
||||
CCCryptorCreate(CCOperation(kCCEncrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionECBMode),
|
||||
key, key.count, nil, &cryptor)
|
||||
}
|
||||
|
||||
deinit { if let cryptor { CCCryptorRelease(cryptor) } }
|
||||
|
||||
/// Counter-mode keystream: AES(iv), AES(iv+1), … (counter in the last 16 bits, RFC 3711 4.1.1).
|
||||
func keystream(iv: [UInt8], count: Int) -> [UInt8] {
|
||||
let blocks = (count + 15) / 16
|
||||
var input = [UInt8](repeating: 0, count: blocks * 16)
|
||||
for b in 0..<blocks {
|
||||
var block = iv
|
||||
let counter = (UInt32(block[14]) << 8 | UInt32(block[15])) &+ UInt32(b)
|
||||
block[14] = UInt8((counter >> 8) & 0xFF)
|
||||
block[15] = UInt8(counter & 0xFF)
|
||||
input.replaceSubrange(b * 16..<(b + 1) * 16, with: block)
|
||||
}
|
||||
var output = [UInt8](repeating: 0, count: input.count)
|
||||
var moved = 0
|
||||
CCCryptorUpdate(cryptor, input, input.count, &output, output.count, &moved)
|
||||
return Array(output.prefix(count))
|
||||
}
|
||||
}
|
||||
|
||||
/// SRTP/SRTCP with the profile SRTP_AES128_CM_HMAC_SHA1_80 (RFC 3711, RFC 5764).
|
||||
/// One context per direction; this side only sends RTP and reads RTCP feedback.
|
||||
final class SRTPContext: @unchecked Sendable {
|
||||
struct SessionKeys {
|
||||
let cipher: AESBlock
|
||||
let salt: [UInt8]
|
||||
let auth: SymmetricKey
|
||||
}
|
||||
|
||||
static let tagLength = 10
|
||||
|
||||
let rtp: SessionKeys
|
||||
let rtcp: SessionKeys
|
||||
private var rolloverCounters: [UInt32: (roc: UInt32, lastSeq: UInt16)] = [:]
|
||||
private var srtcpIndex: UInt32 = 0
|
||||
|
||||
init(masterKey: [UInt8], masterSalt: [UInt8]) {
|
||||
rtp = Self.derive(masterKey: masterKey, masterSalt: masterSalt, labels: (0, 1, 2))
|
||||
rtcp = Self.derive(masterKey: masterKey, masterSalt: masterSalt, labels: (3, 4, 5))
|
||||
}
|
||||
|
||||
/// Key derivation (RFC 3711 4.3) with key_derivation_rate 0.
|
||||
static func deriveKey(masterKey: [UInt8], masterSalt: [UInt8], label: UInt8, length: Int) -> [UInt8] {
|
||||
var x = masterSalt + [0, 0]
|
||||
x[7] ^= label
|
||||
return AESBlock(key: masterKey).keystream(iv: x, count: length)
|
||||
}
|
||||
|
||||
private static func derive(masterKey: [UInt8], masterSalt: [UInt8], labels: (UInt8, UInt8, UInt8)) -> SessionKeys {
|
||||
SessionKeys(
|
||||
cipher: AESBlock(key: deriveKey(masterKey: masterKey, masterSalt: masterSalt, label: labels.0, length: 16)),
|
||||
salt: deriveKey(masterKey: masterKey, masterSalt: masterSalt, label: labels.2, length: 14),
|
||||
auth: SymmetricKey(data: deriveKey(masterKey: masterKey, masterSalt: masterSalt, label: labels.1, length: 20)))
|
||||
}
|
||||
|
||||
/// IV = salt·2^16 ⊕ SSRC·2^64 ⊕ index·2^16.
|
||||
private static func iv(salt: [UInt8], ssrc: UInt32, index: UInt64) -> [UInt8] {
|
||||
var iv = salt + [0, 0]
|
||||
for i in 0..<4 { iv[4 + i] ^= UInt8((ssrc >> UInt32(24 - 8 * i)) & 0xFF) }
|
||||
for i in 0..<6 { iv[8 + i] ^= UInt8((index >> UInt64(40 - 8 * i)) & 0xFF) }
|
||||
return iv
|
||||
}
|
||||
|
||||
/// Encrypts and authenticates one RTP packet. Packets per SSRC must be passed in sending order.
|
||||
func protectRTP(_ packet: [UInt8]) -> [UInt8] {
|
||||
guard packet.count >= 12 else { return packet }
|
||||
let headerLength = Self.rtpHeaderLength(packet)
|
||||
let seq = UInt16(packet[2]) << 8 | UInt16(packet[3])
|
||||
let ssrc = UInt32(packet[8]) << 24 | UInt32(packet[9]) << 16 | UInt32(packet[10]) << 8 | UInt32(packet[11])
|
||||
|
||||
var state = rolloverCounters[ssrc] ?? (0, seq)
|
||||
if seq < state.lastSeq && state.lastSeq - seq > 0x8000 { state.roc &+= 1 }
|
||||
state.lastSeq = seq
|
||||
rolloverCounters[ssrc] = state
|
||||
|
||||
let index = UInt64(state.roc) << 16 | UInt64(seq)
|
||||
var out = packet
|
||||
let payloadCount = packet.count - headerLength
|
||||
if payloadCount > 0 {
|
||||
let stream = rtp.cipher.keystream(iv: Self.iv(salt: rtp.salt, ssrc: ssrc, index: index), count: payloadCount)
|
||||
for i in 0..<payloadCount { out[headerLength + i] ^= stream[i] }
|
||||
}
|
||||
let roc = [UInt8(state.roc >> 24), UInt8((state.roc >> 16) & 0xFF), UInt8((state.roc >> 8) & 0xFF), UInt8(state.roc & 0xFF)]
|
||||
let tag = HMAC<Insecure.SHA1>.authenticationCode(for: out + roc, using: rtp.auth)
|
||||
return out + Array(tag).prefix(Self.tagLength)
|
||||
}
|
||||
|
||||
/// Encrypts and authenticates one (compound) RTCP packet.
|
||||
func protectRTCP(_ packet: [UInt8]) -> [UInt8] {
|
||||
guard packet.count >= 8 else { return packet }
|
||||
srtcpIndex = (srtcpIndex + 1) & 0x7FFF_FFFF
|
||||
let ssrc = UInt32(packet[4]) << 24 | UInt32(packet[5]) << 16 | UInt32(packet[6]) << 8 | UInt32(packet[7])
|
||||
var out = packet
|
||||
let stream = rtcp.cipher.keystream(iv: Self.iv(salt: rtcp.salt, ssrc: ssrc, index: UInt64(srtcpIndex)), count: packet.count - 8)
|
||||
for i in 0..<stream.count { out[8 + i] ^= stream[i] }
|
||||
let e = srtcpIndex | 0x8000_0000
|
||||
out += [UInt8(e >> 24), UInt8((e >> 16) & 0xFF), UInt8((e >> 8) & 0xFF), UInt8(e & 0xFF)]
|
||||
let tag = HMAC<Insecure.SHA1>.authenticationCode(for: out, using: rtcp.auth)
|
||||
return out + Array(tag).prefix(Self.tagLength)
|
||||
}
|
||||
|
||||
/// Verifies and decrypts one SRTCP packet; nil if the tag does not match.
|
||||
func unprotectRTCP(_ packet: [UInt8]) -> [UInt8]? {
|
||||
guard packet.count >= 8 + 4 + Self.tagLength else { return nil }
|
||||
let authEnd = packet.count - Self.tagLength
|
||||
let tag = Array(packet[authEnd...])
|
||||
let expected = Array(HMAC<Insecure.SHA1>.authenticationCode(for: packet[0..<authEnd], using: rtcp.auth)).prefix(Self.tagLength)
|
||||
guard constantTimeEqual(tag, Array(expected)) else { return nil }
|
||||
let e = UInt32(packet[authEnd - 4]) << 24 | UInt32(packet[authEnd - 3]) << 16 | UInt32(packet[authEnd - 2]) << 8 | UInt32(packet[authEnd - 1])
|
||||
var out = Array(packet[0..<authEnd - 4])
|
||||
guard e & 0x8000_0000 != 0 else { return out }
|
||||
let ssrc = UInt32(packet[4]) << 24 | UInt32(packet[5]) << 16 | UInt32(packet[6]) << 8 | UInt32(packet[7])
|
||||
let stream = rtcp.cipher.keystream(iv: Self.iv(salt: rtcp.salt, ssrc: ssrc, index: UInt64(e & 0x7FFF_FFFF)), count: out.count - 8)
|
||||
for i in 0..<stream.count { out[8 + i] ^= stream[i] }
|
||||
return out
|
||||
}
|
||||
|
||||
static func rtpHeaderLength(_ packet: [UInt8]) -> Int {
|
||||
var length = 12 + Int(packet[0] & 0x0F) * 4
|
||||
if packet[0] & 0x10 != 0, packet.count >= length + 4 {
|
||||
length += 4 + (Int(packet[length + 2]) << 8 | Int(packet[length + 3])) * 4
|
||||
}
|
||||
return min(length, packet.count)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Intercom
|
||||
|
||||
/// Real browser against the real server: headless Chrome opens the phone page, connects and decodes.
|
||||
/// Opt-in: `MUSUBI_E2E=1 MUSUBI_E2E_INTERFACE=en0 swift test --filter BrowserEndToEnd`.
|
||||
/// With `MUSUBI_E2E_URL=http://…:8080/` the browser joins a running Musubi instead (connection only).
|
||||
@Suite(.enabled(if: ProcessInfo.processInfo.environment["MUSUBI_E2E"] == "1"))
|
||||
@MainActor struct BrowserEndToEndTests {
|
||||
static let chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
|
||||
@Test(.timeLimit(.minutes(1))) func chromeReceivesTalkbackAndPreview() async throws {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let external = environment["MUSUBI_E2E_URL"].flatMap(URL.init(string:))
|
||||
let server = IntercomServer()
|
||||
if external == nil { await server.start(interfaceName: environment["MUSUBI_E2E_INTERFACE"] ?? "en0") }
|
||||
defer { server.stop() }
|
||||
let url = try #require(external ?? server.url, "Intercom did not start: \(server.error ?? "-")")
|
||||
|
||||
let feeder = Feeder(input: server.input)
|
||||
if external == nil { feeder.start() }
|
||||
defer { feeder.stop() }
|
||||
|
||||
let profile = FileManager.default.temporaryDirectory.appendingPathComponent("musubi-chrome-\(UUID().uuidString)")
|
||||
let port = Int.random(in: 20_000...29_999)
|
||||
let chrome = Process()
|
||||
chrome.executableURL = URL(fileURLWithPath: Self.chrome)
|
||||
chrome.arguments = ["--headless=new", "--remote-debugging-port=\(port)", "--user-data-dir=\(profile.path)",
|
||||
"--autoplay-policy=no-user-gesture-required", "--no-first-run", "--no-default-browser-check",
|
||||
url.absoluteString]
|
||||
chrome.standardError = FileHandle.nullDevice
|
||||
try chrome.run()
|
||||
defer {
|
||||
chrome.terminate()
|
||||
try? FileManager.default.removeItem(at: profile)
|
||||
}
|
||||
|
||||
let devtools = try await DevTools.connect(port: port)
|
||||
for _ in 0..<50 {
|
||||
if try await devtools.evaluate("!!document.querySelector('#join')") == "1" { break }
|
||||
try await Task.sleep(for: .milliseconds(200))
|
||||
}
|
||||
_ = try await devtools.evaluate("""
|
||||
document.querySelector('#name').value = 'Testgerät';
|
||||
document.querySelector('#role').value = 'Kamera 1';
|
||||
document.querySelector('#join').requestSubmit();
|
||||
true
|
||||
""")
|
||||
|
||||
var last = ""
|
||||
for _ in 0..<40 {
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
last = try await devtools.evaluate("""
|
||||
(async () => {
|
||||
if (!pc) return 'none,0,0,' + location.href + ' ' + document.querySelector('#status')?.textContent;
|
||||
let video = 0, audio = 0;
|
||||
(await pc.getStats()).forEach(r => {
|
||||
if (r.type === 'inbound-rtp' && r.kind === 'video') video = r.framesDecoded || 0;
|
||||
if (r.type === 'inbound-rtp' && r.kind === 'audio') audio = r.packetsReceived || 0;
|
||||
});
|
||||
return pc.connectionState + ',' + video + ',' + audio + ',' + document.querySelector('#status').textContent;
|
||||
})()
|
||||
""")
|
||||
let parts = last.split(separator: ",")
|
||||
if parts.first == "connected", external != nil || (Int(parts[1]) ?? 0 >= 20 && Int(parts[2]) ?? 0 >= 50) { break }
|
||||
}
|
||||
let delay = try await devtools.evaluate("""
|
||||
(async () => {
|
||||
let ms = -1;
|
||||
(await pc.getStats()).forEach(r => {
|
||||
if (r.type === 'inbound-rtp' && r.kind === 'video' && r.jitterBufferEmittedCount)
|
||||
ms = Math.round(1000 * r.jitterBufferDelay / r.jitterBufferEmittedCount);
|
||||
});
|
||||
return ms;
|
||||
})()
|
||||
""")
|
||||
print("Browser: \(last) · video jitter buffer \(delay) ms")
|
||||
let parts = last.split(separator: ",").prefix(3)
|
||||
#expect(parts.first == "connected", "state: \(last)")
|
||||
guard external == nil else { return }
|
||||
#expect(Int(parts.dropFirst().first ?? "") ?? 0 >= 20, "video frames decoded: \(last)")
|
||||
#expect(Int(parts.last ?? "") ?? 0 >= 50, "audio packets received: \(last)")
|
||||
#expect(server.participants.first?.name == "Testgerät")
|
||||
#expect(server.participants.first?.connected == true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes a moving test picture (25 fps) and a 440 Hz tone (20 ms blocks at 44.1 kHz to exercise resampling).
|
||||
private final class Feeder: @unchecked Sendable {
|
||||
private let input: IntercomInput
|
||||
private let queue = DispatchQueue(label: "e2e.feeder")
|
||||
private var timer: DispatchSourceTimer?
|
||||
private var tick = 0
|
||||
private var phase = 0.0
|
||||
|
||||
init(input: IntercomInput) { self.input = input }
|
||||
|
||||
func start() {
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now(), repeating: .milliseconds(20))
|
||||
timer.setEventHandler { [weak self] in self?.step() }
|
||||
timer.resume()
|
||||
self.timer = timer
|
||||
}
|
||||
|
||||
func stop() { timer?.cancel() }
|
||||
|
||||
private func step() {
|
||||
tick += 1
|
||||
var samples = [Float](repeating: 0, count: 882)
|
||||
for i in samples.indices {
|
||||
samples[i] = Float(sin(phase) * 0.2)
|
||||
phase += 2 * .pi * 440 / 44_100
|
||||
}
|
||||
samples.withUnsafeBufferPointer { input.pushTalkback($0, sampleRate: 44_100) }
|
||||
guard tick.isMultiple(of: 2) else { return }
|
||||
var buffer: CVPixelBuffer?
|
||||
CVPixelBufferCreate(nil, 1280, 720, kCVPixelFormatType_32BGRA, [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &buffer)
|
||||
guard let buffer else { return }
|
||||
CVPixelBufferLockBaseAddress(buffer, [])
|
||||
let base = CVPixelBufferGetBaseAddress(buffer)!.assumingMemoryBound(to: UInt8.self)
|
||||
let stride = CVPixelBufferGetBytesPerRow(buffer)
|
||||
for y in 0..<720 {
|
||||
for x in 0..<1280 {
|
||||
let p = base + y * stride + x * 4
|
||||
p[0] = UInt8((x + tick * 8) & 0xFF)
|
||||
p[1] = UInt8(y & 0xFF)
|
||||
p[2] = UInt8((tick * 4) & 0xFF)
|
||||
p[3] = 255
|
||||
}
|
||||
}
|
||||
CVPixelBufferUnlockBaseAddress(buffer, [])
|
||||
input.pushVideo(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Just enough of the Chrome DevTools Protocol: Runtime.evaluate on the first page.
|
||||
private final class DevTools: @unchecked Sendable {
|
||||
private let socket: URLSessionWebSocketTask
|
||||
private var nextID = 0
|
||||
|
||||
private init(socket: URLSessionWebSocketTask) { self.socket = socket }
|
||||
|
||||
static func connect(port: Int) async throws -> DevTools {
|
||||
struct Target: Decodable { let type: String; let webSocketDebuggerUrl: String? }
|
||||
for _ in 0..<50 {
|
||||
if let (data, _) = try? await URLSession.shared.data(from: URL(string: "http://127.0.0.1:\(port)/json")!),
|
||||
let targets = try? JSONDecoder().decode([Target].self, from: data),
|
||||
let page = targets.first(where: { $0.type == "page" }), let ws = page.webSocketDebuggerUrl {
|
||||
let socket = URLSession.shared.webSocketTask(with: URL(string: ws)!)
|
||||
socket.resume()
|
||||
return DevTools(socket: socket)
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(200))
|
||||
}
|
||||
throw URLError(.cannotConnectToHost)
|
||||
}
|
||||
|
||||
func evaluate(_ expression: String) async throws -> String {
|
||||
nextID += 1
|
||||
let id = nextID
|
||||
let message: [String: Any] = ["id": id, "method": "Runtime.evaluate",
|
||||
"params": ["expression": expression, "awaitPromise": true, "returnByValue": true]]
|
||||
let data = try JSONSerialization.data(withJSONObject: message)
|
||||
try await socket.send(.string(String(decoding: data, as: UTF8.self)))
|
||||
while true {
|
||||
guard case .string(let text) = try await socket.receive(),
|
||||
let json = try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any],
|
||||
json["id"] as? Int == id else { continue }
|
||||
let result = (json["result"] as? [String: Any])?["result"] as? [String: Any]
|
||||
return result?["value"].map { "\($0)" } ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Intercom
|
||||
|
||||
@Suite struct STUNTests {
|
||||
// RFC 5769 2.1: sample request, password "VOkJxbRl1RmTxUk/WvJxBt".
|
||||
let request = [UInt8](hex: """
|
||||
000100582112a442b7e7a701bc34d686fa87dfae
|
||||
802200105354554e207465737420636c69656e74
|
||||
002400046e0001ff
|
||||
80290008932ff9b151263b36
|
||||
000600096576746a3a68367659202020
|
||||
000800149aeaa70cbfd8cb56781ef2b5b2d3f249c1b571a2
|
||||
80280004e57a3bcf
|
||||
""")!
|
||||
let password = Array("VOkJxbRl1RmTxUk/WvJxBt".utf8)
|
||||
|
||||
@Test func parsesAndVerifiesRFC5769Request() throws {
|
||||
let message = try #require(STUN.parse(request))
|
||||
#expect(message.type == STUN.bindingRequest)
|
||||
#expect(message.username == "evtj:h6vY")
|
||||
#expect(STUN.verify(request, message: message, key: password))
|
||||
#expect(!STUN.verify(request, message: message, key: Array("wrong".utf8)))
|
||||
}
|
||||
|
||||
@Test func rejectsTamperedRequest() throws {
|
||||
var tampered = request
|
||||
tampered[45] ^= 1
|
||||
let message = try #require(STUN.parse(tampered))
|
||||
#expect(!STUN.verify(tampered, message: message, key: password))
|
||||
}
|
||||
|
||||
@Test func buildsVerifiableResponse() throws {
|
||||
let tid = Array(request[8..<20])
|
||||
let response = STUN.bindingResponse(transactionID: tid, address: [192, 0, 2, 1], port: 32853, key: password)
|
||||
let message = try #require(STUN.parse(response))
|
||||
#expect(message.type == STUN.bindingSuccess)
|
||||
#expect(message.transactionID == tid)
|
||||
#expect(STUN.verify(response, message: message, key: password))
|
||||
// RFC 5769 2.2: XOR-MAPPED-ADDRESS for 192.0.2.1:32853.
|
||||
#expect(message.value(.xorMappedAddress) == [UInt8](hex: "0001a147e112a643"))
|
||||
}
|
||||
|
||||
@Test func demultiplexesByFirstByte() {
|
||||
#expect(STUN.looksLikeSTUN(request))
|
||||
#expect(!STUN.looksLikeSTUN([22, 0xFE, 0xFD] + [UInt8](repeating: 0, count: 30)))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct KeyDerivationTests {
|
||||
@Test func tlsPRFMatchesOpenSSL() {
|
||||
let out = TLSPRF.compute(secret: [UInt8](hex: "9bbe436ba940f017b17652849a71db35")!, label: "test label",
|
||||
seed: [UInt8](hex: "a0ba9f936cda311827a6f796ffd5198c")!, length: 100)
|
||||
#expect(out.hex == """
|
||||
e3f229ba727be17b8d122620557cd453c2aab21d07c3d495329b52d4e61edb5a6b301791e90d35c9c9a46b4e14baf9af\
|
||||
0fa022f7077def17abfd3797c0564bab4fbc91666e9def9b97fce34f796789baa48082d122ee42c5a72e5a5110fff701\
|
||||
87347b66
|
||||
""")
|
||||
}
|
||||
|
||||
// RFC 3711 B.2
|
||||
@Test func aesCounterKeystream() {
|
||||
let block = AESBlock(key: [UInt8](hex: "2B7E151628AED2A6ABF7158809CF4F3C")!)
|
||||
let stream = block.keystream(iv: [UInt8](hex: "F0F1F2F3F4F5F6F7F8F9FAFBFCFD0000")!, count: 48)
|
||||
#expect(stream.hex == "e03ead0935c95e80e166b16dd92b4eb4d23513162b02d0f72a43a2fe4a5f97ab41e95b3bb0a2e8dd477901e4fca894c0")
|
||||
}
|
||||
|
||||
// RFC 3711 B.3
|
||||
@Test func srtpKeyDerivation() {
|
||||
let key = [UInt8](hex: "E1F97A0D3E018BE0D64FA32C06DE4139")!
|
||||
let salt = [UInt8](hex: "0EC675AD498AFEEBB6960B3AABE6")!
|
||||
#expect(SRTPContext.deriveKey(masterKey: key, masterSalt: salt, label: 0, length: 16).hex == "c61e7a93744f39ee10734afe3ff7a087")
|
||||
#expect(SRTPContext.deriveKey(masterKey: key, masterSalt: salt, label: 2, length: 14).hex == "30cbbc08863d8c85d49db34a9ae1")
|
||||
#expect(SRTPContext.deriveKey(masterKey: key, masterSalt: salt, label: 1, length: 20).hex == "cebe321f6ff7716b6fd4ab49af256a156d38baa4")
|
||||
}
|
||||
|
||||
@Test func srtcpRoundTrips() throws {
|
||||
let sender = SRTPContext(masterKey: Array(repeating: 7, count: 16), masterSalt: Array(repeating: 9, count: 14))
|
||||
let receiver = SRTPContext(masterKey: Array(repeating: 7, count: 16), masterSalt: Array(repeating: 9, count: 14))
|
||||
// PLI: V=2, FMT=1, PT=206, length 2, sender SSRC, media SSRC
|
||||
let pli: [UInt8] = [0x81, 206, 0, 2, 1, 2, 3, 4, 5, 6, 7, 8]
|
||||
let protected = sender.protectRTCP(pli)
|
||||
#expect(protected != pli)
|
||||
#expect(receiver.unprotectRTCP(protected) == pli)
|
||||
var broken = protected
|
||||
broken[9] ^= 1
|
||||
#expect(receiver.unprotectRTCP(broken) == nil)
|
||||
}
|
||||
|
||||
@Test func srtpEncryptsPayloadOnlyAndAppendsTag() {
|
||||
let context = SRTPContext(masterKey: Array(repeating: 1, count: 16), masterSalt: Array(repeating: 2, count: 14))
|
||||
let packet: [UInt8] = [0x80, 111, 0, 1, 0, 0, 0, 0, 0xAA, 0xBB, 0xCC, 0xDD] + Array(repeating: 0x55, count: 20)
|
||||
let out = context.protectRTP(packet)
|
||||
#expect(out.count == packet.count + 10)
|
||||
#expect(Array(out.prefix(12)) == Array(packet.prefix(12)))
|
||||
#expect(Array(out[12..<32]) != Array(packet[12...]))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct CertificateTests {
|
||||
@Test func selfSignedCertificateCarriesKeyAndValidSignature() throws {
|
||||
let cert = try DTLSCertificate()
|
||||
let spki = try #require(DER.subjectPublicKeyInfo(certificate: cert.der))
|
||||
let publicKey = try P256.Signing.PublicKey(derRepresentation: spki)
|
||||
#expect(publicKey.rawRepresentation == cert.privateKey.publicKey.rawRepresentation)
|
||||
|
||||
// Signature over TBSCertificate verifies with the embedded key.
|
||||
let outer = try #require(DER.elements(cert.der, in: 0..<cert.der.count)?.first)
|
||||
let parts = try #require(DER.elements(cert.der, in: outer.contentRange))
|
||||
#expect(parts.count == 3)
|
||||
let tbs = Array(cert.der[parts[0].range])
|
||||
let signatureBits = Array(cert.der[parts[2].contentRange].dropFirst())
|
||||
let signature = try P256.Signing.ECDSASignature(derRepresentation: signatureBits)
|
||||
#expect(publicKey.isValidSignature(signature, for: tbs))
|
||||
#expect(cert.fingerprint.count == 32 * 3 - 1)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Intercom
|
||||
|
||||
/// Handshake against `openssl s_client` (development tool only, not shipped).
|
||||
/// Skipped when Homebrew OpenSSL is missing.
|
||||
private let opensslPath = "/opt/homebrew/bin/openssl"
|
||||
|
||||
@Suite(.enabled(if: FileManager.default.isExecutableFile(atPath: opensslPath)))
|
||||
struct DTLSInteropTests {
|
||||
static let openssl = opensslPath
|
||||
|
||||
@Test(.timeLimit(.minutes(1)), arguments: [true, false])
|
||||
func handshakeChecksFingerprintAndExportsSameSRTPKeys(fingerprintMatches: Bool) throws {
|
||||
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("musubi-dtls-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let keyPath = dir.appendingPathComponent("client.key").path
|
||||
let certPath = dir.appendingPathComponent("client.pem").path
|
||||
try run([Self.openssl, "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes",
|
||||
"-keyout", keyPath, "-out", certPath, "-subj", "/CN=browser", "-days", "1"])
|
||||
let der = try run([Self.openssl, "x509", "-in", certPath, "-outform", "DER"])
|
||||
|
||||
let expected = fingerprintMatches ? DTLSCertificate.fingerprint(Array(der)) : try DTLSCertificate().fingerprint
|
||||
let server = DTLSServer(certificate: try DTLSCertificate(), expectedFingerprint: expected)
|
||||
|
||||
let socket = Darwin.socket(AF_INET, SOCK_DGRAM, 0)
|
||||
defer { close(socket) }
|
||||
var address = sockaddr_in()
|
||||
address.sin_family = sa_family_t(AF_INET)
|
||||
address.sin_addr.s_addr = inet_addr("127.0.0.1")
|
||||
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
_ = withUnsafePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(socket, $0, length) } }
|
||||
_ = withUnsafeMutablePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(socket, $0, &length) } }
|
||||
var timeout = timeval(tv_sec: 0, tv_usec: 200_000)
|
||||
setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
|
||||
let port = UInt16(bigEndian: address.sin_port)
|
||||
|
||||
let client = Process()
|
||||
client.executableURL = URL(fileURLWithPath: Self.openssl)
|
||||
client.arguments = ["s_client", "-dtls1_2", "-connect", "127.0.0.1:\(port)",
|
||||
"-cipher", "ECDHE-ECDSA-AES128-GCM-SHA256", "-use_srtp", "SRTP_AES128_CM_SHA1_80",
|
||||
"-cert", certPath, "-key", keyPath,
|
||||
"-keymatexport", "EXTRACTOR-dtls_srtp", "-keymatexportlen", "60"]
|
||||
let stdin = Pipe()
|
||||
let stdout = Pipe()
|
||||
client.standardInput = stdin
|
||||
client.standardOutput = stdout
|
||||
client.standardError = stdout
|
||||
try client.run()
|
||||
defer { client.terminate() }
|
||||
|
||||
let output = OutputCollector(stdout.fileHandleForReading)
|
||||
var buffer = [UInt8](repeating: 0, count: 2048)
|
||||
let deadline = Date().addingTimeInterval(fingerprintMatches ? 20 : 3)
|
||||
while Date() < deadline, !output.text.contains("Keying material") {
|
||||
if case .failed = server.state { break }
|
||||
var peer = sockaddr_storage()
|
||||
var peerLength = socklen_t(MemoryLayout<sockaddr_storage>.size)
|
||||
let n = withUnsafeMutablePointer(to: &peer) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { recvfrom(socket, &buffer, buffer.count, 0, $0, &peerLength) }
|
||||
}
|
||||
guard n > 0 else { continue }
|
||||
for reply in server.handle(Array(buffer[0..<n])) {
|
||||
_ = withUnsafePointer(to: &peer) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { sendto(socket, reply, reply.count, 0, $0, peerLength) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard fingerprintMatches else {
|
||||
#expect(server.state == .failed("Client certificate does not match the SDP fingerprint"))
|
||||
#expect(server.keyMaterial == nil)
|
||||
return
|
||||
}
|
||||
#expect(server.state == .established)
|
||||
let text = output.text
|
||||
let line = try #require(text.split(separator: "\n").first { $0.contains("Keying material:") })
|
||||
let exported = String(line.split(separator: ":").last!).trimmingCharacters(in: .whitespaces).lowercased()
|
||||
let keys = try #require(server.keyMaterial)
|
||||
#expect(exported == (keys.clientKey + keys.serverKey + keys.clientSalt + keys.serverSalt).hex)
|
||||
#expect(text.contains("SRTP Extension negotiated, profile=SRTP_AES128_CM_SHA1_80"))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func run(_ arguments: [String]) throws -> Data {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: arguments[0])
|
||||
process.arguments = Array(arguments.dropFirst())
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
try process.run()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private final class OutputCollector: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var data = Data()
|
||||
|
||||
init(_ handle: FileHandle) {
|
||||
handle.readabilityHandler = { [weak self] h in
|
||||
let chunk = h.availableData
|
||||
self?.lock.withLock { self?.data.append(chunk) }
|
||||
}
|
||||
}
|
||||
|
||||
var text: String { lock.withLock { String(decoding: data, as: UTF8.self) } }
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Intercom
|
||||
|
||||
@Suite struct MediaTests {
|
||||
@Test func opusEncodesTwentyMillisecondPackets() throws {
|
||||
let encoder = try #require(TalkbackEncoder())
|
||||
let tone = (0..<4800).map { Float(sin(Double($0) * 2 * .pi * 440 / 48_000) * 0.3) }
|
||||
let packets = tone.withUnsafeBufferPointer { encoder.append($0) }
|
||||
#expect(packets.count == 5)
|
||||
#expect(packets.allSatisfy { !$0.0.isEmpty && $0.0.count < 400 })
|
||||
let steps = zip(packets.dropFirst(), packets).map { $0.1 &- $1.1 }
|
||||
#expect(steps.allSatisfy { $0 == 960 })
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(1))) func previewStartsWithParameterSetsAndKeyFrame() async throws {
|
||||
let encoder = PreviewEncoder(width: 320, height: 180)
|
||||
let received = Received()
|
||||
encoder.onAccessUnit = { nalUnits, _ in received.add(nalUnits) }
|
||||
|
||||
var buffer: CVPixelBuffer?
|
||||
CVPixelBufferCreate(nil, 1280, 720, kCVPixelFormatType_32BGRA,
|
||||
[kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &buffer)
|
||||
let frame = try #require(buffer)
|
||||
for _ in 0..<20 where received.first == nil {
|
||||
encoder.encode(frame)
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
}
|
||||
let nalUnits = try #require(received.first)
|
||||
let types = nalUnits.map { $0[0] & 0x1F }
|
||||
#expect(types.prefix(2) == [7, 8]) // SPS, PPS
|
||||
#expect(types.contains(5)) // IDR
|
||||
// Constrained baseline: profile_idc 66, constraint_set1 flag.
|
||||
#expect(nalUnits[0][1] == 66)
|
||||
#expect(nalUnits[0][2] & 0x40 != 0)
|
||||
}
|
||||
|
||||
private final class Received: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var units: [[[UInt8]]] = []
|
||||
func add(_ nalUnits: [[UInt8]]) { lock.withLock { units.append(nalUnits) } }
|
||||
var first: [[UInt8]]? { lock.withLock { units.first } }
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct HTTPTests {
|
||||
@Test func parsesRequestAcrossChunks() throws {
|
||||
var parser = HTTPRequestParser()
|
||||
let raw = Array("POST /api/offer?x=1 HTTP/1.1\r\nHost: musubi\r\nContent-Length: 11\r\n\r\n{\"a\":\"bcd\"}".utf8)
|
||||
#expect(try parser.append(Array(raw[0..<30])) == nil)
|
||||
let request = try #require(try parser.append(Array(raw[30...])))
|
||||
#expect(request.method == "POST")
|
||||
#expect(request.path == "/api/offer")
|
||||
#expect(request.headers["host"] == "musubi")
|
||||
#expect(request.body == Array("{\"a\":\"bcd\"}".utf8))
|
||||
}
|
||||
|
||||
@Test func rejectsOversizedBody() {
|
||||
var parser = HTTPRequestParser()
|
||||
#expect(throws: HTTPRequestParser.ParseError.self) {
|
||||
try parser.append(Array("POST / HTTP/1.1\r\nContent-Length: 999999\r\n\r\n".utf8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func serializesResponse() {
|
||||
let text = String(decoding: HTTPResponse.text(404, "Nicht gefunden.").serialized, as: UTF8.self)
|
||||
#expect(text.hasPrefix("HTTP/1.1 404 Not Found\r\n"))
|
||||
#expect(text.contains("Content-Length: 15\r\n"))
|
||||
#expect(text.hasSuffix("\r\n\r\nNicht gefunden."))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Intercom
|
||||
|
||||
@Suite struct SDPTests {
|
||||
// Shortened recvonly offer as Chrome/Safari produce it for two transceivers.
|
||||
let offer = """
|
||||
v=0\r
|
||||
o=- 4611731400430051336 2 IN IP4 127.0.0.1\r
|
||||
s=-\r
|
||||
t=0 0\r
|
||||
a=group:BUNDLE 0 1\r
|
||||
a=extmap-allow-mixed\r
|
||||
a=msid-semantic: WMS\r
|
||||
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8\r
|
||||
c=IN IP4 0.0.0.0\r
|
||||
a=rtcp:9 IN IP4 0.0.0.0\r
|
||||
a=ice-ufrag:Ab3x\r
|
||||
a=ice-pwd:k9Qmw0Rr1qY2yT7cXv8sJp4u\r
|
||||
a=ice-options:trickle\r
|
||||
a=fingerprint:sha-256 4c:1b:00:8f:6d:3f:9b:39:72:20:4e:69:ef:4a:16:1c:c2:39:d9:e4:3f:1e:f1:5b:3b:91:85:73:a0:cd:7a:4e\r
|
||||
a=setup:actpass\r
|
||||
a=mid:0\r
|
||||
a=recvonly\r
|
||||
a=rtcp-mux\r
|
||||
a=rtpmap:111 opus/48000/2\r
|
||||
a=fmtp:111 minptime=10;useinbandfec=1\r
|
||||
a=rtpmap:63 red/48000/2\r
|
||||
a=rtpmap:9 G722/8000\r
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103 127 125\r
|
||||
c=IN IP4 0.0.0.0\r
|
||||
a=ice-ufrag:Ab3x\r
|
||||
a=ice-pwd:k9Qmw0Rr1qY2yT7cXv8sJp4u\r
|
||||
a=fingerprint:sha-256 4c:1b:00:8f:6d:3f:9b:39:72:20:4e:69:ef:4a:16:1c:c2:39:d9:e4:3f:1e:f1:5b:3b:91:85:73:a0:cd:7a:4e\r
|
||||
a=setup:actpass\r
|
||||
a=mid:1\r
|
||||
a=recvonly\r
|
||||
a=rtcp-mux\r
|
||||
a=rtpmap:96 VP8/90000\r
|
||||
a=rtpmap:97 rtx/90000\r
|
||||
a=fmtp:97 apt=96\r
|
||||
a=rtpmap:102 H264/90000\r
|
||||
a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\r
|
||||
a=rtpmap:103 H264/90000\r
|
||||
a=fmtp:103 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\r
|
||||
a=rtpmap:127 H264/90000\r
|
||||
a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r
|
||||
a=rtpmap:125 H264/90000\r
|
||||
a=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f\r
|
||||
|
||||
"""
|
||||
|
||||
@Test func parsesOffer() throws {
|
||||
let parsed = try SDPOffer(offer)
|
||||
#expect(parsed.iceUfrag == "Ab3x")
|
||||
#expect(parsed.icePwd == "k9Qmw0Rr1qY2yT7cXv8sJp4u")
|
||||
#expect(parsed.fingerprint.hasPrefix("4C:1B:00:8F"))
|
||||
#expect(parsed.media.map(\.kind) == ["audio", "video"])
|
||||
#expect(parsed.media.map(\.mid) == ["0", "1"])
|
||||
#expect(parsed.opusPayloadType(in: parsed.media[0]) == 111)
|
||||
// Constrained baseline with packetization-mode=1 wins.
|
||||
#expect(parsed.h264PayloadType(in: parsed.media[1]) == 127)
|
||||
}
|
||||
|
||||
@Test func missingCredentialsFail() {
|
||||
#expect(throws: SDPOffer.ParseError.missing("ice-ufrag")) { try SDPOffer("v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n") }
|
||||
}
|
||||
|
||||
@Test func answerIsIceLitePassiveSendOnly() throws {
|
||||
let answer = SDPAnswer(iceUfrag: "musu", icePwd: "0123456789abcdefghijklmn", fingerprint: "AA:BB", address: "192.168.1.20",
|
||||
port: 50000, tracks: [.init(mid: "0", kind: "audio", payloadType: 111, ssrc: 11),
|
||||
.init(mid: "1", kind: "video", payloadType: 127, ssrc: 22)]).text
|
||||
#expect(answer.contains("a=ice-lite\r\n"))
|
||||
#expect(answer.contains("a=group:BUNDLE 0 1\r\n"))
|
||||
#expect(answer.contains("m=audio 50000 UDP/TLS/RTP/SAVPF 111\r\n"))
|
||||
#expect(answer.contains("m=video 50000 UDP/TLS/RTP/SAVPF 127\r\n"))
|
||||
#expect(answer.contains("a=setup:passive\r\n"))
|
||||
#expect(answer.contains("a=candidate:1 1 udp 2130706431 192.168.1.20 50000 typ host\r\n"))
|
||||
#expect(!answer.contains("a=recvonly"))
|
||||
}
|
||||
|
||||
@Test func rejectedTrackGetsPortZero() {
|
||||
let answer = SDPAnswer(iceUfrag: "u", icePwd: "p", fingerprint: "F", address: "10.0.0.1", port: 1,
|
||||
tracks: [.init(mid: "0", kind: "audio", payloadType: 111, ssrc: 1),
|
||||
.init(mid: "1", kind: "video", payloadType: nil, ssrc: 2)]).text
|
||||
#expect(answer.contains("a=group:BUNDLE 0\r\n"))
|
||||
#expect(answer.contains("m=video 0 UDP/TLS/RTP/SAVPF 0\r\n"))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct RTPTests {
|
||||
@Test func headerFields() {
|
||||
var stream = RTPStream(ssrc: 0x0102_0304, payloadType: 111)
|
||||
let first = stream.packet(timestamp: 960, marker: true, payload: [9, 9])
|
||||
let second = stream.packet(timestamp: 1920, marker: false, payload: [])
|
||||
#expect(first[0] == 0x80)
|
||||
#expect(first[1] == 0x80 | 111)
|
||||
#expect(Array(first[4..<12]) == [0, 0, 3, 0xC0, 1, 2, 3, 4])
|
||||
#expect(Array(first[12...]) == [9, 9])
|
||||
let seq1 = UInt16(first[2]) << 8 | UInt16(first[3])
|
||||
let seq2 = UInt16(second[2]) << 8 | UInt16(second[3])
|
||||
#expect(seq2 == seq1 &+ 1)
|
||||
}
|
||||
|
||||
@Test func aggregatesParameterSetsAndKeepsSmallNALs() {
|
||||
let sps: [UInt8] = [0x67, 1, 2, 3]
|
||||
let pps: [UInt8] = [0x68, 4]
|
||||
let idr: [UInt8] = [0x65] + Array(repeating: 7, count: 100)
|
||||
let payloads = H264Packetizer.payloads(for: [sps, pps, idr])
|
||||
#expect(payloads.count == 2)
|
||||
#expect(payloads[0] == [0x78, 0, 4] + sps + [0, 2] + pps) // STAP-A with NRI 3
|
||||
#expect(payloads[1] == idr)
|
||||
}
|
||||
|
||||
@Test func fragmentsLargeNALAndReassembles() {
|
||||
let nal: [UInt8] = [0x65] + (0..<5000).map { UInt8($0 & 0xFF) }
|
||||
let payloads = H264Packetizer.payloads(for: [nal])
|
||||
#expect(payloads.count > 1)
|
||||
#expect(payloads.allSatisfy { $0.count <= H264Packetizer.maxPayload })
|
||||
#expect(payloads.first?[1] == 0x85) // start bit + type 5
|
||||
#expect(payloads.last?[1] == 0x45) // end bit + type 5
|
||||
#expect(payloads.allSatisfy { $0[0] == 0x7C }) // FU-A, NRI 3
|
||||
let rebuilt = [UInt8(0x65)] + payloads.flatMap { $0.dropFirst(2) }
|
||||
#expect(rebuilt == nal)
|
||||
}
|
||||
|
||||
@Test func splitsAVCC() {
|
||||
let avcc: [UInt8] = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 3, 0x65, 2, 3]
|
||||
#expect(H264Packetizer.nalUnits(avcc: avcc) == [[0x67, 1], [0x65, 2, 3]])
|
||||
}
|
||||
|
||||
@Test func detectsKeyFrameRequests() {
|
||||
let receiverReport: [UInt8] = [0x80, 201, 0, 1, 0, 0, 0, 1]
|
||||
let pli: [UInt8] = [0x81, 206, 0, 2, 0, 0, 0, 1, 0, 0, 0, 2]
|
||||
#expect(RTCPFeedback.requestsKeyFrame(receiverReport + pli))
|
||||
#expect(!RTCPFeedback.requestsKeyFrame(receiverReport))
|
||||
#expect(RTCPFeedback.isRTCP(pli))
|
||||
#expect(!RTCPFeedback.isRTCP([0x80, 111, 0, 1, 0, 0, 0, 0]))
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |