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
+227 -10
View File
@@ -30,6 +30,23 @@ struct MessageItem: Identifiable, Equatable, Codable
let sentAt: Double
}
// tab
// conversations() messages 线 /
// reachable线 APNs 线 iOS ConversationView 线
// 线 / sendableDevices
struct ConversationItem: Identifiable, Equatable
{
let peerName: String
let deviceID: String // id /
let deviceType: String
let online: Bool
let reachable: Bool // 线
let lastText: String
let lastAt: Double // epoch 0
let lastOutgoing: Bool
var id: String { peerName }
}
// web engine/main.ts toWire ice*
// P2P TURN
struct TransferItem: Identifiable, Equatable, Codable
@@ -68,6 +85,24 @@ struct LogEntry: Identifiable, Equatable
let level: String // warn | error | info
let message: String
let at: Date
// "session <id> ..." id + pion
// / SSE / nil
let sessionId: String?
init(level: String, message: String, at: Date)
{
self.level = level
self.message = message
self.at = at
self.sessionId = LogEntry.parseSession(message)
}
static func parseSession(_ msg: String) -> String?
{
guard msg.hasPrefix("session ") else { return nil }
let id = msg.dropFirst("session ".count).prefix { !$0.isWhitespace }
return id.isEmpty ? nil : String(id)
}
}
// #8/link?r=&c=
@@ -117,10 +152,13 @@ final class EngineController: NSObject
// #8 / /
var pendingLoginRequest: LoginRequestItem?
// #5 / tab activeTab
// 使seenIncomingTransfers
//
var unreadMessages = 0
// #5 tab activeTab 使
// seenIncomingTransfers
// unreadByPeer tab
// unreadMessages activeConversation 使
var unreadByPeer: [String: Int] = [:]
var activeConversation: String?
var unreadMessages: Int { unreadByPeer.values.reduce(0, +) }
var unreadTransfers = 0
var activeTab = "transfer"
private var seenIncomingTransfers: Set<String> = []
@@ -155,6 +193,14 @@ final class EngineController: NSObject
// WKURLSchemeHandler
private var outgoing: [String: URL] = [:]
// libwebrtcSafari RTCPeerConnection WKWebView 256KB SCTP
// + desktop/NATIVE-TRANSFER.md §7 gomobile/pionpion Go raw
// socket iOS Network framework P2P RPC
// Documents DownloadManager p2pEvents
// 宿libwebrtc 线 / 线宿
private var transferEngine: LibWebRtcEngine?
private var p2pEvents: P2PEventsBridge?
// /
private static let historyCap = 50
private static let messagesCap = 200
@@ -276,7 +322,8 @@ final class EngineController: NSObject
// / / / /
notices = []
logs = []
unreadMessages = 0
unreadByPeer = [:]
activeConversation = nil
unreadTransfers = 0
seenIncomingTransfers = []
activeTab = "transfer"
@@ -307,7 +354,11 @@ final class EngineController: NSObject
func clearHistory()
{
history.removeAll()
RecordsStore.clear(recordKey("history"))
// activateRecords 退 legacy
// [] 使 load 退
// legacy
persistHistory()
RecordsStore.clear("history")
}
// H/
@@ -320,7 +371,18 @@ final class EngineController: NSObject
func clearMessages()
{
messages.removeAll()
RecordsStore.clear(recordKey("messages"))
// clearHistory legacy 退 legacy
persistMessages()
RecordsStore.clear("messages")
}
// /
//
//
func deleteConversation(_ peerName: String)
{
messages.removeAll { $0.peerName == peerName }
persistMessages()
}
// ---- #2 ----
@@ -350,10 +412,28 @@ final class EngineController: NSObject
func setActiveTab(_ tab: String)
{
activeTab = tab
if tab == "messages" { unreadMessages = 0 }
// tab
// markConversationRead tab
if tab == "transfer" { unreadTransfers = 0 }
}
// ConversationView.onAppear + onDisappear nil
func setActiveConversation(_ peerName: String?)
{
activeConversation = peerName
}
func markConversationRead(_ peerName: String)
{
if unreadByPeer[peerName] != nil { unreadByPeer[peerName] = 0 }
}
//
func unread(for peerName: String) -> Int
{
return unreadByPeer[peerName] ?? 0
}
// noteIncomingTransfer sessionId
//
private func noteIncomingTransfer(_ item: TransferItem)
@@ -405,6 +485,57 @@ final class EngineController: NSObject
return Self.isWakeable(dev) ? "\(dev.name) · \(t("home.device.wakeable"))" : dev.name
}
// ---- ----
// 线 /
// presence / 线 lastAt=0
//
func conversations() -> [ConversationItem]
{
// messages
var latest: [String: MessageItem] = [:]
for m in messages where latest[m.peerName] == nil { latest[m.peerName] = m }
let reachable = sendableDevices()
let reachableNames = Set(reachable.map { $0.name })
//
var peers = Set(latest.keys)
peers.formUnion(reachableNames)
peers.remove(deviceName) //
var items: [ConversationItem] = []
for peer in peers
{
let dev = devices.first { $0.name == peer }
let last = latest[peer]
items.append(ConversationItem(
peerName: peer,
deviceID: dev?.deviceID ?? "",
deviceType: dev?.type ?? "",
online: dev?.online ?? false,
reachable: reachableNames.contains(peer),
lastText: last?.text ?? "",
lastAt: last?.sentAt ?? 0,
lastOutgoing: last?.direction == "outgoing"))
}
//
return items.sorted { a, b in a.lastAt != b.lastAt ? a.lastAt > b.lastAt : a.peerName < b.peerName }
}
// 线
func thread(with peerName: String) -> [MessageItem]
{
return messages.filter { $0.peerName == peerName }.sorted { $0.sentAt < $1.sentAt }
}
// 线 APNs sendableDevices 线
// 线 /
func canMessage(_ peerName: String) -> Bool
{
return sendableDevices().contains { $0.name == peerName }
}
// UIPasteboard
func uploadClipboard()
{
@@ -617,6 +748,54 @@ final class EngineController: NSObject
let data = (try handle.read(upToCount: count)) ?? Data()
return data.base64EncodedString()
}
// ---- gomobile/pion----
// Documents DownloadManager P2PEventsBridge
// 线 JSlibwebrtc RPC error
private func nativeTransferEngine() -> LibWebRtcEngine
{
if let e = transferEngine { return e }
let events = P2PEventsBridge()
events.controller = self
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].path
let e = LibWebRtcEngine(downloadDir: dir, delegate: events)
p2pEvents = events
transferEngine = e
return e
}
// cdrop-file://<id> Go
// 访 stageOutgoingFile reset Go os.Open
//
private func resolveOutgoingPath(_ ref: String) -> String
{
guard let id = URL(string: ref)?.host, let url = outgoing[id] else { return "" }
return url.path
}
// resolveOnMain libwebrtc 线线线 resolve RPC
// nonisolated 使线 DispatchQueue.main + assumeIsolated @MainActor
// resolveevaluateJavaScript 线errornil ok=false退 /
nonisolated private func resolveOnMain(id: Int, error: Error?)
{
DispatchQueue.main.async
{
MainActor.assumeIsolated
{
self.resolve(id: id, ok: error == nil, value: error?.localizedDescription)
}
}
}
// #3 P2PEventsBridge.onLog 线
func appendNativeLog(_ msg: String)
{
guard !msg.isEmpty else { return }
lastEngineLog = msg
logs.insert(LogEntry(level: "info", message: msg, at: Date()), at: 0)
if logs.count > Self.logsCap { logs.removeLast(logs.count - Self.logsCap) }
}
}
// MARK: - JS RPC +
@@ -669,6 +848,40 @@ extension EngineController: WKScriptMessageHandler
start: Self.intOf(payload["start"]),
end: Self.intOf(payload["end"]))
resolve(id: id, ok: true, value: b64)
case "p2pStartOutgoing":
// filePath cdrop-file://<id>
// libwebrtc completion offer + resolveOnMain
let engine = nativeTransferEngine()
let path = resolveOutgoingPath(payload["filePath"] as? String ?? "")
guard !path.isEmpty
else { resolve(id: id, ok: false, value: "no staged file"); return }
let sid = payload["sessionId"] as? String ?? ""
let peer = payload["peerName"] as? String ?? ""
let ice = payload["iceServersJSON"] as? String ?? ""
engine.startOutgoing(sessionId: sid, peerName: peer, filePath: path, iceServersJSON: ice)
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
case "p2pStartIncoming":
// PeerConnection offer Documents
let engine = nativeTransferEngine()
let sid = payload["sessionId"] as? String ?? ""
let peer = payload["peerName"] as? String ?? ""
let ice = payload["iceServersJSON"] as? String ?? ""
engine.startIncoming(sessionId: sid, peerName: peer, iceServersJSON: ice)
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
case "p2pHandleSignal":
// JS hub SSE completion
// JS offer/answer trickle p2pIos.ts chain
guard let engine = transferEngine
else { resolve(id: id, ok: false, value: "no native session"); return }
let from = payload["fromPeer"] as? String ?? ""
let pj = payload["payloadJSON"] as? String ?? ""
engine.handleSignal(fromPeer: from, payloadJSON: pj)
{ [weak self] err in self?.resolveOnMain(id: id, error: err) }
case "p2pCancel":
// / 退 OnStatelibwebrtc close resolve
let sid = payload["sessionId"] as? String ?? ""
transferEngine?.cancel(sessionId: sid)
resolve(id: id, ok: true, value: nil)
default:
resolve(id: id, ok: false, value: "unknown method: \(method)")
}
@@ -729,8 +942,12 @@ extension EngineController: WKScriptMessageHandler
messages.removeLast(messages.count - Self.messagesCap)
}
persistMessages()
// #5outgoing
if isNew, item.direction == "incoming", activeTab != "messages" { unreadMessages += 1 }
//
// outgoing
if isNew, item.direction == "incoming", item.peerName != activeConversation
{
unreadByPeer[item.peerName, default: 0] += 1
}
}
case "transferDone":
// sessionId
@@ -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() }
}
}
@@ -0,0 +1,74 @@
import Foundation
// P2PEventsBridgelibwebrtc LibWebRtcEngine宿 libwebrtc
// 线线 DispatchQueue.main.async 线 EngineController
// JS evaluateJavaScript 线
//
// DispatchQueue.main.async Task { @MainActor } FIFO使
// connected completed Task completed connected
// 退assumeIsolated EngineController @MainActor
//
// desktop/app.go transferEvents Wails EventsEmitiOS cdropEngine
// LibWebRtcEngine.swift P2PEngineDelegateJS
// web/src/features/transfer/p2pIos.ts / payload NativeP2PHandlers
final class P2PEventsBridge: P2PEngineDelegate
{
weak var controller: EngineController?
// ack
func engineDidProgress(sessionId: String, bytes: Int64)
{
emit("p2pProgress", [ "sessionId": sessionId, "bytes": bytes ])
}
// connected / completed / failed / closed
func engineDidChangeState(sessionId: String, state: String)
{
emit("p2pState", [ "sessionId": sessionId, "state": state ])
}
// offer/answer/icepayloadJSON JSON JS POST /api/hub/signal
// payload JSON.parse p2pNative onSignal
func engineDidEmitSignal(sessionId: String, toPeer: String, payloadJSON: String)
{
emit("p2pSignal", [
"sessionId": sessionId,
"to": toPeer,
"payload": payloadJSON,
])
}
// Documents transferDone JS
// p2pIos onSaved no-op
func engineDidSave(sessionId: String, path: String)
{
emit("p2pSaved", [ "sessionId": sessionId, "path": path ])
}
// "host/udp" iceStats
func engineDidSelectIcePair(sessionId: String, local: String, remote: String)
{
emit("p2pIcePair", [
"sessionId": sessionId,
"local": local,
"remote": remote,
])
}
// / ICE / / #3
// JS便 mDNS/ICE 线
func engineDidLog(_ line: String)
{
let msg = line
DispatchQueue.main.async { [weak controller] in
MainActor.assumeIsolated { controller?.appendNativeLog(msg) }
}
}
private func emit(_ name: String, _ payload: [String: Any])
{
DispatchQueue.main.async { [weak controller] in
MainActor.assumeIsolated { controller?.sendCommand(name, payload: payload) }
}
}
}