iOS 原生客户端:libwebrtc 数据面 + UX 改造 + 品牌/字体 + 全平台平均速度

- 原生数据面 libwebrtc(PLAN 决策 F):新增 Engine/LibWebRtcEngine.swift(RTCPeerConnection,端口 engine.go/session.go 线协议 + 水位背压 + ack 完成 + 不冲突落盘);NativeTransfer.swift 的 P2PEventsBridge 改 conform P2PEngineDelegate(去 gomobile);EngineController 4 个 p2p RPC 改调新引擎(completion-based + resolveOnMain)。stasel/WebRTC M149 经 SPM(exactVersion 149.0.0)。环回端到端 20MB 逐字节测过(Tests/)
- UX 改造(PARITY 残余项 1/2):新增 SendComposeSheet(发送预览=文件名 + 选设备,手动多选与分享共用);分享直入预览 + scenePhase .active 兜底(修“分享后必须重启 app”);重写 MessagesView(按设备会话 + 每设备未读红点 + 删单条 / 删整设备历史);EngineController 加 conversations / unread 模型
- 品牌 logo:新增 Shared/Brand.swift(BrandMark / Wordmark / BrandLockup / BrandHero)+ Brand.xcassets/LogoMark;登录页 / 传输空态 / 设置头 / 控制中心控件接 logo;Theme 加 cdropAmber
- 字体(对齐 web,仅主 app):新增 BrandFonts.swift(Orbit Gothic + Maple Mono woff2 运行期 CTFontManager 注册 + UIKit 外观代理 + .oXxx / .maple 助手);Fredoka SemiBold 字标;7 视图 .font sweep。字体不入库——.gitignore 排除 + just ios-fonts 构建前从 CDN / Google Fonts 拉取,见 Sources/Fonts/README.md
- 分享引导:新增 Share/ShareGuideView(吉祥物卡 + “打开 Commilitia Drop”);ShareViewController 健壮 staging(文件 / 数据双路)+ 响应链 IMP 唤起宿主
- 全平台平均速度:传输完成态显平均速度(总字节 / 总耗时)而非末次瞬时——web store/slices/transfer.ts + TransferRow.tsx + iOS RootView 卡片 / 详情;i18n 加 transfer.avgRate
- 工程:project.yml 接 SPM WebRTC + CDropTests + Fredoka UIAppFonts + Share i18n;Justfile 加 ios-sim-build + ios-fonts / ios-fonts-fredoka 配方
- 文档:ios/PLAN.md(决策 F + §10 UX + §11 品牌/字体 + §12 平均速度)、ios/PARITY.md(字体分叉条目更新 + 2026-06-28 漂移行)
This commit is contained in:
2026-06-28 19:33:08 +08:00
parent f1a00d128e
commit 349d94aa8f
39 changed files with 2535 additions and 295 deletions
@@ -0,0 +1,905 @@
import Foundation
import WebRTC
// LibWebRtcEngine iOS libwebrtcSafari RTCPeerConnection
// P2P gomobile/pion pion Go raw socket iOS Network framework
// WebKit/libwebrtc desktop/NATIVE-TRANSFER.md §7
//
// 线 desktop/enginepion web/src/features/transfer/p2p.ts pion
// / iOS JS DataChannel "cdrop-file"ordered meta/done/ack
// 64KB payload {type, sdp?, candidate?} session.go +
// engine.go Swift ack
//
// WebRTC + I/O HTTP POST / POST JS
// web/src/features/transfer/p2pIos.ts P2PEngineDelegate 宿 EngineController
// cdropEngine P2PEventsBridge JS
//
// 宿EngineController 4 p2p RPC + net/ios.ts nativeP2P* Swift
// gomobile EngineEngine web IOS_NATIVE=true
// MARK: - 宿 desktop/engine Events
protocol P2PEngineDelegate: AnyObject
{
// ack ack 退
func engineDidProgress(sessionId: String, bytes: Int64)
// connected / completed / failed / closed
func engineDidChangeState(sessionId: String, state: String)
// offer/answer/icepayloadJSON JSON 宿 POST /api/hub/signal
func engineDidEmitSignal(sessionId: String, toPeer: String, payloadJSON: String)
// Documents
func engineDidSave(sessionId: String, path: String)
// "host/udp"
func engineDidSelectIcePair(sessionId: String, local: String, remote: String)
// / ICE / / "session <id> ..."
func engineDidLog(_ line: String)
}
// MARK: - 线 desktop/engine wire.goweb p2p.ts
private enum Wire
{
static let channelName = "cdrop-file"
static let chunkSize = 64 * 1024
static let highWatermark: UInt64 = 16 * 1024 * 1024
static let lowWatermark: UInt64 = 4 * 1024 * 1024
static let ackIntervalMs = 200
static let progressThrottleNs: Int64 = 100 * 1_000_000 // ~10Hz宿线 evaluateJavaScript
static let ackCompleteTimeout: TimeInterval = 30
// ICE web p2p.ts ICE_CANDIDATE_POOL
static let iceCandidatePool: Int32 = 4
}
struct EngineError: LocalizedError
{
let msg: String
init(_ m: String) { msg = m }
var errorDescription: String? { msg }
}
// MARK: - engine.go Engine
final class LibWebRtcEngine
{
// SSL libwebrtc let 线
private static let sslReady: Bool = RTCInitializeSSL()
private let factory: RTCPeerConnectionFactory
// init EngineController P2PEventsBridge宿
weak var delegate: P2PEngineDelegate?
private let lock = NSLock()
private var byPeer: [String: P2PTransferSession] = [:]
private var downloadDirValue: String
init(downloadDir: String, delegate: P2PEngineDelegate)
{
_ = LibWebRtcEngine.sslReady
factory = RTCPeerConnectionFactory()
downloadDirValue = downloadDir
self.delegate = delegate
}
var events: P2PEngineDelegate? { delegate }
func downloadDir() -> String
{
lock.lock(); defer { lock.unlock() }
return downloadDirValue
}
func setDownloadDir(_ dir: String)
{
lock.lock(); downloadDirValue = dir; lock.unlock()
}
// StartOutgoing PeerConnection + DataChannel offer filePath
// filePath cdrop-file://<id> completion offer
// setLocalDescription JS seed p2pIos.ts chain
func startOutgoing(sessionId: String, peerName: String, filePath: String,
iceServersJSON: String, completion: @escaping (Error?) -> Void)
{
let fm = FileManager.default
var isDir: ObjCBool = false
guard fm.fileExists(atPath: filePath, isDirectory: &isDir), !isDir.boolValue
else { completion(EngineError("not a file: \(filePath)")); return }
let size = ((try? fm.attributesOfItem(atPath: filePath))?[.size] as? NSNumber)?.int64Value ?? 0
let s = P2PTransferSession(engine: self, factory: factory,
sessionId: sessionId, peerName: peerName, role: .sender)
s.filePath = filePath
s.fileSize = size
do { try s.createPeerConnection(iceServersJSON) }
catch { completion(error); return }
put(peerName, s)
do { try s.createSenderDataChannel() }
catch { s.fail("create datachannel: \(error.localizedDescription)"); completion(error); return }
s.createOfferAndEmit(completion: completion)
}
// StartIncoming PeerConnection offer DataChannel 线
// offer completion offer/ice
func startIncoming(sessionId: String, peerName: String,
iceServersJSON: String, completion: @escaping (Error?) -> Void)
{
let s = P2PTransferSession(engine: self, factory: factory,
sessionId: sessionId, peerName: peerName, role: .receiver)
do { try s.createPeerConnection(iceServersJSON) }
catch { completion(error); return }
put(peerName, s)
completion(nil)
}
// HandleSignal宿 SSE completion
// offer answer answer setRemoteDescription
// ice addIceCandidate JS offer/answer trickle
func handleSignal(fromPeer: String, payloadJSON: String, completion: @escaping (Error?) -> Void)
{
lock.lock(); let s = byPeer[fromPeer]; lock.unlock()
guard let s
else { completion(EngineError("no active session for peer \(fromPeer)")); return }
s.handleSignal(payloadJSON, completion: completion)
}
// Cancel / 退宿 OnState
func cancel(sessionId: String)
{
lock.lock()
var target: P2PTransferSession?
for (_, s) in byPeer where s.sessionId == sessionId { target = s; break }
lock.unlock()
target?.cleanup()
}
fileprivate func put(_ peer: String, _ s: P2PTransferSession)
{
lock.lock()
let old = byPeer[peer]
byPeer[peer] = s
lock.unlock()
// 沿 web / RPC / 线
// close engine.go `go old.cleanup()`
if let old, old !== s { DispatchQueue.global(qos: .utility).async { old.cleanup() } }
}
fileprivate func remove(_ peer: String, _ s: P2PTransferSession)
{
lock.lock()
if byPeer[peer] === s { byPeer.removeValue(forKey: peer) }
lock.unlock()
}
}
// MARK: - pc + dc + session.go session
final class P2PTransferSession: NSObject
{
enum Role { case sender, receiver }
let sessionId: String
let peerName: String
let role: Role
private weak var engine: LibWebRtcEngine?
private let factory: RTCPeerConnectionFactory
private var pc: RTCPeerConnection?
private var dc: RTCDataChannel?
//
var filePath = ""
var fileSize: Int64 = 0
// 线 / pion / dc cLock
private let cLock = NSLock()
private var bytesSent: Int64 = 0 // SCTP
private var receivedBytes: Int64 = 0 //
private var ackedBytes: Int64 = 0 // ack
private var lastEmitNs: Int64 = 0 //
// outLock outHandle //writeChunk libwebrtc 线cleanup
// 线JS p2pCancel / 退 / SSE ARC retain/release close/write
// Go *os.File fdMutex Swift FileHandle
private var outHandle: FileHandle?
private var outName = ""
private var tmpPath = ""
private let outLock = NSLock()
// /
private let streamQueue: DispatchQueue
private let bufSem = DispatchSemaphore(value: 0) // didChangeBufferedAmount / ack
private var ackTimer: DispatchSourceTimer?
private var senderStarted = false
//
private let stateLock = NSLock()
private var state = ""
private var doneFlag = false
private var cleanedUp = false
init(engine: LibWebRtcEngine, factory: RTCPeerConnectionFactory,
sessionId: String, peerName: String, role: Role)
{
self.engine = engine
self.factory = factory
self.sessionId = sessionId
self.peerName = peerName
self.role = role
streamQueue = DispatchQueue(label: "net.commilitia.cdrop.p2p.\(sessionId)")
super.init()
}
// MARK:
func createPeerConnection(_ iceServersJSON: String) throws
{
let config = RTCConfiguration()
config.iceServers = Self.parseICEServers(iceServersJSON)
config.sdpSemantics = .unifiedPlan
config.continualGatheringPolicy = .gatherOnce
config.iceCandidatePoolSize = Wire.iceCandidatePool
// IPv4libwebrtc iOS Network framework
// hosthost iOS IPv4-only
// iOS-WebKit IPv6 host 退
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
guard let pc = factory.peerConnection(with: config, constraints: constraints, delegate: self)
else { throw EngineError("failed to create peer connection") }
self.pc = pc
}
func createSenderDataChannel() throws
{
guard let pc else { throw EngineError("no pc") }
let dcConfig = RTCDataChannelConfiguration()
dcConfig.isOrdered = true
guard let dc = pc.dataChannel(forLabel: Wire.channelName, configuration: dcConfig)
else { throw EngineError("failed to create data channel") }
dc.delegate = self
self.dc = dc
}
func createOfferAndEmit(completion: @escaping (Error?) -> Void)
{
guard let pc else { completion(EngineError("no pc")); return }
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
pc.offer(for: constraints)
{ [weak self] sdp, err in
guard let self else { completion(EngineError("session gone")); return }
if let err { self.fail("create offer: \(err.localizedDescription)"); completion(err); return }
guard let sdp else { self.fail("create offer: nil"); completion(EngineError("nil offer")); return }
pc.setLocalDescription(sdp)
{ [weak self] err2 in
guard let self else { completion(EngineError("session gone")); return }
if let err2
{
self.fail("set local (offer): \(err2.localizedDescription)"); completion(err2); return
}
self.emitSignal(sdp: sdp, candidate: nil)
completion(nil)
}
}
}
// MARK:
func handleSignal(_ payloadJSON: String, completion: @escaping (Error?) -> Void)
{
guard let pc else { completion(EngineError("no pc")); return }
guard let data = payloadJSON.data(using: .utf8),
let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let type = obj["type"] as? String
else { completion(EngineError("decode signal")); return }
log("signal in: \(type)")
switch type
{
case "offer":
guard let sdpObj = obj["sdp"] as? [String: Any], let sdpStr = sdpObj["sdp"] as? String
else { completion(EngineError("offer missing sdp")); return }
let desc = RTCSessionDescription(type: .offer, sdp: sdpStr)
pc.setRemoteDescription(desc)
{ [weak self] err in
guard let self else { completion(EngineError("gone")); return }
if let err { completion(err); return }
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
pc.answer(for: constraints)
{ [weak self] ans, aerr in
guard let self else { completion(EngineError("gone")); return }
if let aerr { completion(aerr); return }
guard let ans else { completion(EngineError("nil answer")); return }
pc.setLocalDescription(ans)
{ [weak self] lerr in
guard let self else { completion(EngineError("gone")); return }
if let lerr { completion(lerr); return }
self.emitSignal(sdp: ans, candidate: nil)
completion(nil)
}
}
}
case "answer":
guard let sdpObj = obj["sdp"] as? [String: Any], let sdpStr = sdpObj["sdp"] as? String
else { completion(EngineError("answer missing sdp")); return }
let desc = RTCSessionDescription(type: .answer, sdp: sdpStr)
pc.setRemoteDescription(desc) { err in completion(err) }
case "ice":
// / end-of-candidatespion nil candidate
guard let candObj = obj["candidate"] as? [String: Any],
let candStr = candObj["candidate"] as? String, !candStr.isEmpty
else { completion(nil); return }
let mid = candObj["sdpMid"] as? String
let mline = (candObj["sdpMLineIndex"] as? NSNumber)?.int32Value ?? 0
let cand = RTCIceCandidate(sdp: candStr, sdpMLineIndex: mline, sdpMid: mid)
pc.add(cand) { err in completion(err) }
default:
completion(nil)
}
}
// MARK:
// session.go emitSignal candidate ice sdp offer/answer
// JSON web p2p.ts SignalPayload / pion signalPayload parse
private func emitSignal(sdp: RTCSessionDescription?, candidate: RTCIceCandidate?)
{
var payload: [String: Any] = [:]
if let candidate
{
payload["type"] = "ice"
// RTCIceCandidateObjC usernameFragment candidate JSON candidate/sdpMid/
// sdpMLineIndexufrag m-line ice-ufrag offer/answer pion
// addIceCandidate usernameFragment
var c: [String: Any] = [
"candidate": candidate.sdp,
"sdpMLineIndex": Int(candidate.sdpMLineIndex),
]
c["sdpMid"] = candidate.sdpMid ?? NSNull()
payload["candidate"] = c
}
else if let sdp
{
let t = Self.sdpTypeString(sdp.type)
payload["type"] = t
payload["sdp"] = [ "type": t, "sdp": sdp.sdp ]
}
else { return }
guard let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8)
else { return }
engine?.events?.engineDidEmitSignal(sessionId: sessionId, toPeer: peerName, payloadJSON: json)
}
// MARK:
private func startSenderStream()
{
cLock.lock()
if senderStarted { cLock.unlock(); return }
senderStarted = true
cLock.unlock()
streamQueue.async { [weak self] in self?.streamFile() }
}
// streamFile 64KB pion / web
// HIGH LOW done ack completed
private func streamFile()
{
guard let dc else { fail("no data channel"); return }
let fh: FileHandle
do { fh = try FileHandle(forReadingFrom: URL(fileURLWithPath: filePath)) }
catch { fail("open \(filePath): \(error.localizedDescription)"); return }
defer { try? fh.close() }
if !sendText([ "type": "meta", "name": (filePath as NSString).lastPathComponent, "size": fileSize ])
{
fail("send meta"); return
}
while true
{
if isDone() { return }
let chunk: Data?
do { chunk = try fh.read(upToCount: Wire.chunkSize) }
catch { fail("read \(filePath): \(error.localizedDescription)"); return }
guard let chunk, !chunk.isEmpty else { break } // EOF
if dc.bufferedAmount + UInt64(chunk.count) > Wire.highWatermark
{
waitBufferLow(Wire.lowWatermark)
}
if isDone() { return }
if !dc.sendData(RTCDataBuffer(data: chunk, isBinary: true))
{
fail("send chunk failed (channel closed?)"); return
}
addSent(Int64(chunk.count))
emitProgress()
}
_ = sendText([ "type": "done" ])
waitDrained()
waitAck(fileSize)
emitProgressNow()
setState("completed")
}
@discardableResult
private func sendText(_ dict: [String: Any]) -> Bool
{
guard let dc, let data = try? JSONSerialization.data(withJSONObject: dict) else { return false }
return dc.sendData(RTCDataBuffer(data: data, isBinary: false))
}
// threshold didChangeBufferedAmount bufSem 50ms
private func waitBufferLow(_ threshold: UInt64)
{
let start = Date()
var warned = false
while true
{
guard let dc else { return }
if dc.bufferedAmount <= threshold { return }
if isDone() { return }
_ = bufSem.wait(timeout: .now() + 0.05)
if !warned, Date().timeIntervalSince(start) > 5
{
warned = true
log("send stalled >5s: buffered=\(dc.bufferedAmount) sent=\(getSent()) acked=\(getAcked())")
}
}
}
// done SCTP
private func waitDrained()
{
while true
{
guard let dc else { return }
if dc.bufferedAmount == 0 { return }
if isDone() { return }
_ = bufSem.wait(timeout: .now() + 0.02)
}
}
// ack total ack session.go waitAck
private func waitAck(_ total: Int64)
{
if getAcked() == 0 { return }
let deadline = Date().addingTimeInterval(Wire.ackCompleteTimeout)
while getAcked() < total
{
if Date() > deadline { return }
if isDone() { return }
_ = bufSem.wait(timeout: .now() + 0.05)
}
}
// MARK:
private func bindReceiverDataChannel(_ channel: RTCDataChannel)
{
dc = channel
channel.delegate = self
startAckLoop()
}
// 200ms
private func startAckLoop()
{
let timer = DispatchSource.makeTimerSource(queue: streamQueue)
timer.schedule(deadline: .now() + .milliseconds(Wire.ackIntervalMs),
repeating: .milliseconds(Wire.ackIntervalMs))
var lastSent: Int64 = -1
timer.setEventHandler
{ [weak self] in
guard let self else { return }
let r = self.getReceived()
if r != lastSent { self.sendAck(r); lastSent = r }
}
// cleanup didOpen cancel
stateLock.lock()
if cleanedUp { stateLock.unlock(); timer.cancel(); return }
ackTimer = timer
stateLock.unlock()
timer.resume()
}
private func sendAck(_ bytes: Int64)
{
guard let dc, dc.readyState == .open,
let data = try? JSONSerialization.data(withJSONObject: [ "type": "ack", "bytes": bytes ])
else { return }
_ = dc.sendData(RTCDataBuffer(data: data, isBinary: false))
}
// ack meta / done
private func handleControl(_ data: Data)
{
guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let type = obj["type"] as? String
else { return }
switch type
{
case "meta":
if role == .receiver { openOutput(name: obj["name"] as? String ?? "download") }
case "done":
if role == .receiver { finalizeReceive() }
case "ack":
if role == .sender, let b = (obj["bytes"] as? NSNumber)?.int64Value
{
setAcked(b)
bufSem.signal() // waitAck / waitDrained
emitProgress()
}
default:
break
}
}
private func openOutput(name: String)
{
outName = name
let dir = engine?.downloadDir() ?? NSTemporaryDirectory()
let tmp = (dir as NSString).appendingPathComponent(".\(sessionId).part")
tmpPath = tmp
FileManager.default.createFile(atPath: tmp, contents: nil)
let handle = FileHandle(forWritingAtPath: tmp)
outLock.lock(); outHandle = handle; outLock.unlock()
if handle == nil { fail("create \(tmp)") }
}
// base64 OPFS WebView dc
private func writeChunk(_ data: Data)
{
// outLock cleanup / outHandle
outLock.lock()
if let h = outHandle
{
do { try h.write(contentsOf: data) }
catch { outLock.unlock(); fail("write chunk: \(error.localizedDescription)"); return }
}
outLock.unlock()
_ = addReceived(Int64(data.count))
emitProgress()
}
// doneorderedreceivedBytes ack
// completed NSObject finalize() override
private func finalizeReceive()
{
sendAck(getReceived())
outLock.lock(); let h = outHandle; outHandle = nil; outLock.unlock()
if let h
{
try? h.close()
let dir = engine?.downloadDir() ?? NSTemporaryDirectory()
let final = Self.nonCollidingPath(dir: dir, name: outName)
do { try FileManager.default.moveItem(atPath: tmpPath, toPath: final) }
catch { fail("rename to \(final): \(error.localizedDescription)"); return }
engine?.events?.engineDidSave(sessionId: sessionId, path: final)
}
emitProgressNow()
setState("completed")
}
// MARK: / /
// ack ack 退
// session.go progressBytes
private func progressBytes() -> Int64
{
if role == .sender
{
let a = getAcked()
if a > 0 { return min(fileSize, a) }
let buffered = Int64(dc?.bufferedAmount ?? 0)
let b = getSent() - buffered
return b < 0 ? 0 : b
}
return getReceived()
}
// ~10Hz宿线 evaluateJavaScript
private func emitProgress()
{
let now = Int64(DispatchTime.now().uptimeNanoseconds)
cLock.lock()
if now - lastEmitNs < Wire.progressThrottleNs { cLock.unlock(); return }
lastEmitNs = now
cLock.unlock()
engine?.events?.engineDidProgress(sessionId: sessionId, bytes: progressBytes())
}
// 宿 99%
private func emitProgressNow()
{
cLock.lock(); lastEmitNs = Int64(DispatchTime.now().uptimeNanoseconds); cLock.unlock()
engine?.events?.engineDidProgress(sessionId: sessionId, bytes: progressBytes())
}
private func setState(_ st: String)
{
stateLock.lock()
// completed/failed 便 closed/failed 退
if state == st || state == "completed" || state == "failed"
{
stateLock.unlock(); return
}
state = st
stateLock.unlock()
engine?.events?.engineDidChangeState(sessionId: sessionId, state: st)
switch st
{
case "failed", "closed":
cleanup()
case "completed":
// ack ack
// ackLoop 60s 宿 SSE cancel
streamQueue.asyncAfter(deadline: .now() + 60) { [weak self] in self?.cleanup() }
default:
break
}
}
func fail(_ msg: String)
{
log("fail: \(msg)")
setState("failed")
}
func cleanup()
{
stateLock.lock()
if cleanedUp { stateLock.unlock(); return }
cleanedUp = true
doneFlag = true
let timer = ackTimer // startAckLoop stateLock ackTimer ARC
ackTimer = nil
stateLock.unlock()
bufSem.signal() // / ack
timer?.cancel()
// outHandle outLock writeChunk writeChunk cleanup
// close/write ARC
outLock.lock(); let h = outHandle; outHandle = nil; outLock.unlock()
if let h { try? h.close() }
dc?.close()
pc?.close()
engine?.remove(peerName, self)
}
// MARK: cLock
private func addSent(_ n: Int64) { cLock.lock(); bytesSent += n; cLock.unlock() }
private func getSent() -> Int64 { cLock.lock(); defer { cLock.unlock() }; return bytesSent }
private func addReceived(_ n: Int64) -> Int64
{
cLock.lock(); receivedBytes += n; let r = receivedBytes; cLock.unlock(); return r
}
private func getReceived() -> Int64 { cLock.lock(); defer { cLock.unlock() }; return receivedBytes }
private func setAcked(_ v: Int64) { cLock.lock(); if v > ackedBytes { ackedBytes = v }; cLock.unlock() }
private func getAcked() -> Int64 { cLock.lock(); defer { cLock.unlock() }; return ackedBytes }
private func isDone() -> Bool { stateLock.lock(); defer { stateLock.unlock() }; return doneFlag }
// MARK: best-effort
private func logSelectedPair()
{
// streamQueue streamFile
//
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3)
{ [weak self] in self?.querySelectedPair(attempt: 0) }
}
private func querySelectedPair(attempt: Int)
{
guard attempt < 12, !isDone(), let pc else { return }
pc.statistics
{ [weak self] report in
guard let self else { return }
if let pair = Self.extractSelectedPair(report)
{
self.log("ice pair: local=\(pair.0) remote=\(pair.1)")
self.engine?.events?.engineDidSelectIcePair(sessionId: self.sessionId,
local: pair.0, remote: pair.1)
}
else
{
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3)
{ [weak self] in self?.querySelectedPair(attempt: attempt + 1) }
}
}
}
// MARK:
private static func parseICEServers(_ raw: String) -> [RTCIceServer]
{
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8),
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]]
else { return [] }
var out: [RTCIceServer] = []
for s in arr
{
var urls: [String] = []
if let one = s["urls"] as? String { urls = [one] }
else if let many = s["urls"] as? [String] { urls = many }
if urls.isEmpty { continue }
let username = s["username"] as? String
let credential = s["credential"] as? String
if let username, let credential, !username.isEmpty
{
out.append(RTCIceServer(urlStrings: urls, username: username, credential: credential))
}
else
{
out.append(RTCIceServer(urlStrings: urls))
}
}
return out
}
// dir namename (1)name (2) wire.go nonCollidingPath
private static func nonCollidingPath(dir: String, name: String) -> String
{
let fm = FileManager.default
let base = (dir as NSString).appendingPathComponent(name)
if !fm.fileExists(atPath: base) { return base }
let ext = (name as NSString).pathExtension
let stem = (name as NSString).deletingPathExtension
var i = 1
while true
{
let candidate = ext.isEmpty ? "\(stem) (\(i))" : "\(stem) (\(i)).\(ext)"
let p = (dir as NSString).appendingPathComponent(candidate)
if !fm.fileExists(atPath: p) { return p }
i += 1
}
}
private static func extractSelectedPair(_ report: RTCStatisticsReport) -> (String, String)?
{
let stats = report.statistics
var localId: String?
var remoteId: String?
for (_, s) in stats where s.type == "candidate-pair"
{
let v = s.values
let succeeded = (v["state"] as? String) == "succeeded"
let nominated = (v["nominated"] as? NSNumber)?.boolValue ?? false
if succeeded, nominated
{
localId = v["localCandidateId"] as? String
remoteId = v["remoteCandidateId"] as? String
break
}
}
guard let localId, let remoteId else { return nil }
func label(_ id: String) -> String
{
guard let c = stats[id] else { return "?" }
let t = (c.values["candidateType"] as? String) ?? "?"
let proto = (c.values["protocol"] as? String) ?? "?"
return "\(t)/\(proto)"
}
return (label(localId), label(remoteId))
}
private static func sdpTypeString(_ t: RTCSdpType) -> String
{
switch t
{
case .offer: return "offer"
case .answer: return "answer"
case .prAnswer: return "pranswer"
case .rollback: return "rollback"
@unknown default: return "offer"
}
}
private static func iceConnString(_ s: RTCIceConnectionState) -> String
{
switch s
{
case .new: return "new"
case .checking: return "checking"
case .connected: return "connected"
case .completed: return "completed"
case .failed: return "failed"
case .disconnected: return "disconnected"
case .closed: return "closed"
case .count: return "count"
@unknown default: return "?"
}
}
// SDP "type/proto"best-effort
// candidate:<foundation> <component> <proto> <priority> <ip> <port> typ <type> ...
private static func candTypeString(_ sdp: String) -> String
{
let parts = sdp.split(separator: " ").map(String.init)
var typ = "?"
var proto = "?"
if parts.count > 2 { proto = parts[2].lowercased() }
if let i = parts.firstIndex(of: "typ"), i + 1 < parts.count { typ = parts[i + 1] }
return "\(typ)/\(proto)"
}
private func log(_ msg: String)
{
engine?.events?.engineDidLog("session \(sessionId) \(msg)")
}
}
// MARK: - RTCPeerConnectionDelegate libwebrtc 线线
extension P2PTransferSession: RTCPeerConnectionDelegate
{
func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState)
{
log("ice: \(Self.iceConnString(newState))")
}
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState)
{
if newState == .complete { log("local gathering done") }
}
func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate)
{
log("cand local: \(Self.candTypeString(candidate.sdp))")
emitSignal(sdp: nil, candidate: candidate)
}
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel)
{
if role == .receiver { bindReceiverDataChannel(dataChannel) }
}
// ice+dtls session.go OnConnectionStateChange connected/failed/closed
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState)
{
switch newState
{
case .connected:
setState("connected")
logSelectedPair()
case .failed:
fail("peerconnection failed")
case .closed:
setState("closed")
default:
break
}
}
}
// MARK: - RTCDataChannelDelegate libwebrtc 线
extension P2PTransferSession: RTCDataChannelDelegate
{
func dataChannelDidChangeState(_ dataChannel: RTCDataChannel)
{
// streamFile session.go dc.OnOpen go streamFile
if role == .sender, dataChannel.readyState == .open { startSenderStream() }
}
func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer)
{
if buffer.isBinary { writeChunk(buffer.data) }
else { handleControl(buffer.data) }
}
func dataChannel(_ dataChannel: RTCDataChannel, didChangeBufferedAmount amount: UInt64)
{
if amount <= Wire.lowWatermark { bufSem.signal() }
}
}