You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

155 lines
6.4 KiB

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"
}
}

Powered by TurnKey Linux.