Compare commits
9 Commits
439e2505eb
...
e4e29aa5dd
| Author | SHA1 | Date |
|---|---|---|
|
|
e4e29aa5dd | 3 days ago |
|
|
c61c543a65 | 3 days ago |
|
|
1dc6b58d8a | 3 days ago |
|
|
7801d04b44 | 3 days ago |
|
|
7d885ecd57 | 3 days ago |
|
|
3e245b1ae3 | 4 days ago |
|
|
0ff5e2f907 | 4 days ago |
|
|
2527b18e8d | 4 days ago |
|
|
318d75ff69 | 4 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 |