原生数据面协议层:桌面 engine 进度节流 / pion 日志 + iOS 数据面路由
- 桌面 engine(pion 数据面,承 ab57afd 之后的精修):
- 新增 logging.go——把 pion 内部日志路由到宿主 OnLog(真机无 stderr,连接失败无从查);仅 ice/mdns 作用域放 Debug、余 Info+、Trace 丢弃,按 session 聚合
- wire.go / session.go / engine.go:进度回调 ~10Hz 节流(progressEmitThrottleMs,高吞吐下每片一回调打满宿主主线程;终态由 emitProgressNow 强发最终值),与 web store push 同量级;engine_test.go 跟进
- web 数据面路由:p2p.ts 翻 IOS_NATIVE=true(iOS 走原生 libwebrtc 引擎)+ 新增 p2pIos.ts(iOS p2p 后端)+ p2pNative.ts / net/ios.ts 跟进
- 数据面设计文档 NATIVE-TRANSFER.md:U1(gomobile+pion)真机证伪 → 翻案 U2(libwebrtc)的依据与实测证据(§7/§8)
- 线协议三端一致(cdrop-file ordered / meta+chunk(64KB)+done+ack / 16MB-4MB 水位 / ack 追平完成),桌面 pion ↔ iOS libwebrtc ↔ web JS 互通
This commit is contained in:
@@ -124,9 +124,19 @@ ICE creds:Go 拉 /api/calls/credentials(带 a.token),与 JS 同源
|
||||
|
||||
---
|
||||
|
||||
## 7. iOS 数据面:经 gomobile 与桌面共用同一份 Go 引擎(U1,POC 闸)
|
||||
## 7. iOS 数据面:libwebrtc(U2,POC 翻案后定)
|
||||
|
||||
**决策(2026-06-27,研究后翻案)**:iOS 不再「维持现状 + 仅靠 striping」,而是**把数据面也迁到 Go/pion,经 `gomobile bind` 与桌面共用同一份引擎**——原生侧只一份实现(web 仍 JS,共 2 份引擎),iOS 彻底摆脱 WebKit 256KB。依据用户方针「多端统一 + 各情况性能」,且桌面已定 pion——iOS 同走 pion 即与桌面**同引擎、同特性、同协议**,统一性最大。**该决策以一个 2 天 POC 为闸**(见 §8 Phase 2)。
|
||||
> **⚠️ 决策反转(2026-06-28,真机 POC 证伪 U1)**:下方 U1(gomobile+pion)方案在真机 POC 阶段**撞上架构性硬阻塞、已废弃**,按 §8 Phase 2 闸“撞硬阻塞→退 U2”转用 **libwebrtc.framework**。
|
||||
>
|
||||
> 实测证据:iOS 经 gomobile/pion 时 Go 的 raw BSD socket 不与 iOS Network framework 集成(WebKit/libwebrtc 才集成)——Mac→iOS 常连不上(~30s ICE 超时)、iOS→Mac 偶连上 host↔host 但 ~4.5MB 中途断流(`read/write on closed pipe`)、TURN CreatePermission 刷屏、时好时坏。接口 / 链路本地 / IPv4-only 过滤只去噪、治不了架构不兼容(且那些 iOS 定向过滤漏进桌面共享引擎致 IPv6 host 对被裁退中继、重大回归)。即下方 §137 标注的“LOW-MEDIUM 成熟度、无 pion+gomobile 生产先例”风险兑现。
|
||||
>
|
||||
> **U2 落地(现行)**:JS 路由 / 桥协议 / 线协议 / `IOS_NATIVE` 开关全不变,只把 Swift 侧原生引擎从 gomobile `EngineEngine` 换成 `ios/CDrop/Sources/Engine/LibWebRtcEngine.swift`(libwebrtc `RTCPeerConnection`,stasel/WebRTC M149 经 SPM、pin 精确版本 149.0.0)。该引擎是 `engine.go`/`session.go` 线协议的忠实 Swift 端口——DataChannel `cdrop-file`(ordered)、meta/分片(64KB)/done/ack 文本+二进制帧、16MB/4MB 水位背压、ack 追平完成、不冲突落盘,逐字节对齐桌面 pion 与 web JS 引擎,故 iOS↔桌面、iOS↔浏览器互通。桌面数据面仍走 pion(isDesktop 分支,不受影响)。libwebrtc 与 iOS 网络栈原生集成 + DcSCTP 默认 ~5MB rwnd(不受 WebKit 256KB 限),是吞吐与连通性的根治。
|
||||
>
|
||||
> **验证(2026-06-28)**:模拟器全 app(含 Share/控件扩展)编译过 + web typecheck/build 过;**环回端到端测试**(两个 LibWebRtcEngine 同进程交叉连、20MB 文件经完整线协议传输后逐字节完整性比对)✅ 过(`ios/CDrop/Tests/LibWebRtcEngineTests.swift`,`just ios-sim-build` 同款免签名构建)。真机 / 真网吞吐为剩余闸(须 deploy prod 让设备拿到 `IOS_NATIVE=true` 的引擎 + 真机装机,二者 Touch ID 门控)。
|
||||
>
|
||||
> 以下 U1 分析保留作历史依据(解释为何最终落 U2、以及 U2 的对照评估)。
|
||||
|
||||
**(历史·已废弃)决策(2026-06-27,研究后翻案)**:iOS 不再「维持现状 + 仅靠 striping」,而是**把数据面也迁到 Go/pion,经 `gomobile bind` 与桌面共用同一份引擎**——原生侧只一份实现(web 仍 JS,共 2 份引擎),iOS 彻底摆脱 WebKit 256KB。依据用户方针「多端统一 + 各情况性能」,且桌面已定 pion——iOS 同走 pion 即与桌面**同引擎、同特性、同协议**,统一性最大。**该决策以一个 2 天 POC 为闸**(见 §8 Phase 2)。
|
||||
|
||||
**研究结论(U1 = gomobile + pion):**
|
||||
- **可行,但不直接 bind pion**(pion 公开 API 含 gobind 不支持的类型,pion#1111)。正解=写一层 gobind-clean 的 `engine` 薄包装内部引 pion,再 `gomobile bind ./engine` 出 `.xcframework`。pion 维护者本人推荐 gomobile(discussion#1746)。
|
||||
@@ -167,6 +177,15 @@ ICE creds:Go 拉 /api/calls/credentials(带 a.token),与 JS 同源
|
||||
| **Phase 1(落地 A·桌面)** | Go `p2pengine`(pion,单关联)+ `p2pBackend` 抽象 + 桥接 + 原生取文件路径;relay/编排留 JS | 桌面↔桌面/Chrome/iOS-LAN **双向快**;隐藏窗口传输满速;去 base64 桥;mac 落 host↔host |
|
||||
| **Phase 2(iOS POC 闸,~2 天)** | 最小 `engine` 包装 → `gomobile bind -target ios`(用最新 `golang.org/x/mobile`)→ Swift 调 `StartOutgoing` | ① 编译出 xcframework;② iOS 上 Go 开 UDP 拿到 **host 候选** + 本地网络权限弹窗;③ 真跑 `iOS(pion)↔桌面(pion)` 与 `iOS(pion)↔浏览器` 传输达预期。**过→锁 U1;撞硬阻塞→退 U2** |
|
||||
| **Phase 3(落地 U1·iOS)** | iOS 数据面接入共享 Go 引擎(gomobile xcframework + iOS 侧 `p2pBackend` binding);JS 编排/hub/presence 留 WKWebView | iOS 摆脱 256KB;iOS↔* 高 RTT 大幅改善;与桌面**同引擎** |
|
||||
|
||||
> **进度(2026-06-28):Phase 2 POC 证伪 U1 → 转 U2(libwebrtc);Phase 3(U2 版)✅ 代码完成、模拟器编译 + 环回端到端测试绿,待真机/真网吞吐验。**
|
||||
> - **Phase 2 闸结果=撞硬阻塞、退 U2**:gomobile bind 出 `CdropEngine.xcframework` 编译过(①),但真机 ②③ 失败——Go raw socket 不与 iOS Network framework 集成,连接时好时坏 + 中途断流(详见 §7 反转 banner 实测证据)。按闸“撞硬阻塞→退 U2”转 libwebrtc。U1 集成(gomobile 桥)已整体移除。
|
||||
> - **U2 集成已落地**(JS 桥 / 协议 / `IOS_NATIVE` 开关不变,仅换 Swift 引擎;桥协议本就引擎无关):
|
||||
> - Swift:`ios/CDrop/Sources/Engine/LibWebRtcEngine.swift`(`LibWebRtcEngine` + `P2PTransferSession`,libwebrtc RTCPeerConnection,端口 `engine.go`/`session.go` 线协议 + 水位背压 + ack 完成 + 不冲突落盘);`NativeTransfer.swift` 的 `P2PEventsBridge` 改 conform `P2PEngineDelegate`(去 gomobile `import CdropEngine`,事件名 / payload 形状不变);`EngineController` 4 个 p2p RPC 改调新引擎(completion-based,经 `resolveOnMain` 跳主线程 resolve;去 gomobile `EngineEngine` / `runEngine` / `engineQueue`——libwebrtc 自管线程)。
|
||||
> - 构建:`project.yml` 去 `Frameworks/CdropEngine.xcframework`、接 SPM `WebRTC`(`exactVersion 149.0.0`)+ 加 `CDropTests` 测试 target;`Justfile` 去 `ios-engine` recipe(gomobile bind)、加 `ios-sim-build`(模拟器免签名自检)、`ios-device` 去 chain 的 `ios-engine`。
|
||||
> - JS:`p2p.ts` 的 `IOS_NATIVE` 翻 `true`(`p2pIos.ts` / `net/ios.ts` 一字不改)。
|
||||
> - **测试**:`ios/CDrop/Tests/LibWebRtcEngineTests.swift` 环回端到端(两个引擎同进程交叉连,20MB 逐字节完整性)✅;模拟器全 app 编译 ✅;web typecheck + build ✅。
|
||||
> - **真机测试前提**(同 U1):iOS 加载 `engine.html` 自 prod,故须让设备拿到含 `IOS_NATIVE=true` 的引擎——deploy prod(gated,浏览器/桌面零影响)或 `CDROP_ENGINE_URL` 指本地安全源(见 REALDEVICE §C);真机装机走 `just ios-device <udid>`(首次自动经 SPM 拉 `WebRTC.xcframework`,无 gomobile 预构建步骤)。
|
||||
| **Phase 4(按需)** | 共享协议 **N 关联 striping**(JS 引擎 for web;Go 引擎 for 桌面+iOS) | 解跨 NAT 高 RTT 残差(全端) |
|
||||
|
||||
---
|
||||
|
||||
@@ -52,6 +52,13 @@ func New(cfg Config, ev Events) *Engine {
|
||||
return &Engine{cfg: cfg, ev: ev, byPeer: make(map[string]*session)}
|
||||
}
|
||||
|
||||
// NewEngine 是 gomobile-friendly 构造器:以 downloadDir 字符串 + Events 接口构造,避开 New 的
|
||||
// Config 值参数——gomobile 不绑定按值传递的结构体(故 New 在 iOS 头里被 skip)。桌面侧仍用 New;
|
||||
// iOS Swift 经 xcframework 调本函数。二者构造同一 *Engine,无行为差异。
|
||||
func NewEngine(downloadDir string, ev Events) *Engine {
|
||||
return New(Config{DownloadDir: downloadDir}, ev)
|
||||
}
|
||||
|
||||
// SetDownloadDir 更新落地目录(设置页改下载目录时调用)。
|
||||
func (e *Engine) SetDownloadDir(dir string) {
|
||||
e.mu.Lock()
|
||||
@@ -75,7 +82,7 @@ func (e *Engine) StartOutgoing(sessionID, peerName, filePath, iceServersJSON str
|
||||
if fi.IsDir() {
|
||||
return fmt.Errorf("%s is a directory", filePath)
|
||||
}
|
||||
pc, err := newPeerConnection(iceServersJSON)
|
||||
pc, err := newPeerConnection(sessionID, iceServersJSON, e.ev.OnLog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,7 +116,7 @@ func (e *Engine) StartOutgoing(sessionID, peerName, filePath, iceServersJSON str
|
||||
// StartIncoming 作为接收端预备:建 PeerConnection 等对端 offer。收到 DataChannel 后按
|
||||
// 线协议接收并直接落盘。须在对端 offer 到达前调用(宿主在 transfer:incoming 时预备)。
|
||||
func (e *Engine) StartIncoming(sessionID, peerName, iceServersJSON string) error {
|
||||
pc, err := newPeerConnection(iceServersJSON)
|
||||
pc, err := newPeerConnection(sessionID, iceServersJSON, e.ev.OnLog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func (p *peerEvents) OnSaved(_ string, path string) {
|
||||
}
|
||||
}
|
||||
func (p *peerEvents) OnIcePair(_ string, _ string, _ string) {}
|
||||
func (p *peerEvents) OnLog(line string) { p.t.Log(line) }
|
||||
func (p *peerEvents) OnLog(line string) { p.t.Log(line) }
|
||||
|
||||
// TestEngineTransfer 端到端:两个进程内 pion peer(A 发 / B 收)经 host 候选直连,传一个
|
||||
// 3MB 已知内容的文件,断言落盘字节与源一致。覆盖线协议(meta/chunk/done/ack)、水位回压、
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pion/logging"
|
||||
)
|
||||
|
||||
// emitLoggerFactory 把 pion 内部日志路由到宿主 OnLog——真机上无 stderr,否则「为何连不上」无从查。
|
||||
// 只在连接建立相关作用域(ice / mdns)放开 Debug(候选对检查、ping 失败、sendto 权限错误等关键线索),
|
||||
// 其余作用域仅 Info 及以上,避免数据面(sctp/dtls)刷屏。Trace 一律丢弃(过量)。
|
||||
type emitLoggerFactory struct {
|
||||
emit func(string)
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func (f emitLoggerFactory) NewLogger(scope string) logging.LeveledLogger {
|
||||
return emitLogger{scope: scope, emit: f.emit, sessionID: f.sessionID}
|
||||
}
|
||||
|
||||
type emitLogger struct {
|
||||
scope string
|
||||
sessionID string
|
||||
emit func(string)
|
||||
}
|
||||
|
||||
// fwd 统一以 "session <id> ..." 起头,与 session.go 的诊断行同格式,便于宿主按 session 聚合日志。
|
||||
func (l emitLogger) fwd(level, msg string) {
|
||||
if l.emit != nil {
|
||||
l.emit(fmt.Sprintf("session %s pion %s/%s: %s", l.sessionID, l.scope, level, msg))
|
||||
}
|
||||
}
|
||||
|
||||
// debugOn 仅对连接建立作用域放开 Debug,限制日志量。
|
||||
func (l emitLogger) debugOn() bool { return l.scope == "ice" || l.scope == "mdns" }
|
||||
|
||||
func (l emitLogger) Trace(string) {}
|
||||
func (l emitLogger) Tracef(string, ...interface{}) {}
|
||||
|
||||
func (l emitLogger) Debug(msg string) {
|
||||
if l.debugOn() {
|
||||
l.fwd("D", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (l emitLogger) Debugf(format string, args ...interface{}) {
|
||||
if l.debugOn() {
|
||||
l.fwd("D", fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l emitLogger) Info(msg string) { l.fwd("I", msg) }
|
||||
func (l emitLogger) Infof(format string, args ...interface{}) {
|
||||
l.fwd("I", fmt.Sprintf(format, args...))
|
||||
}
|
||||
func (l emitLogger) Warn(msg string) { l.fwd("W", msg) }
|
||||
func (l emitLogger) Warnf(format string, args ...interface{}) {
|
||||
l.fwd("W", fmt.Sprintf(format, args...))
|
||||
}
|
||||
func (l emitLogger) Error(msg string) { l.fwd("E", msg) }
|
||||
func (l emitLogger) Errorf(format string, args ...interface{}) {
|
||||
l.fwd("E", fmt.Sprintf(format, args...))
|
||||
}
|
||||
+46
-15
@@ -35,9 +35,10 @@ type session struct {
|
||||
fileSize int64
|
||||
|
||||
// 计数器(跨 goroutine:发送循环 / pion 回调)。
|
||||
bytesSent atomic.Int64 // 已塞进本地 SCTP 缓冲的字节
|
||||
received atomic.Int64 // 接收端已写盘字节
|
||||
acked atomic.Int64 // 接收端经 ack 回传的已收字节(发送端进度/完成依据)
|
||||
bytesSent atomic.Int64 // 已塞进本地 SCTP 缓冲的字节
|
||||
received atomic.Int64 // 接收端已写盘字节
|
||||
acked atomic.Int64 // 接收端经 ack 回传的已收字节(发送端进度/完成依据)
|
||||
lastEmitNs atomic.Int64 // emitProgress 节流时间戳(纳秒):限频进度回调,护宿主主线程
|
||||
|
||||
// 接收端落盘
|
||||
out *os.File
|
||||
@@ -67,8 +68,15 @@ func newSession(e *Engine, id, peer, role string, pc *webrtc.PeerConnection) *se
|
||||
// 接收窗顶到 4MB(远高于 WebKit 写死的 256KB),是吞吐专项的核心——使本端作接收端时
|
||||
// `吞吐 ≈ rwnd/RTT` 的天花板抬高一个量级。pion 默认用真实 IP 的 host 候选(不做 mDNS
|
||||
// 混淆),故同内网天然落到 host↔host 直连,避开 mac WKWebView 的 srflx 退化。
|
||||
func newPeerConnection(iceServersJSON string) (*webrtc.PeerConnection, error) {
|
||||
func newPeerConnection(sessionID, iceServersJSON string, log func(string)) (*webrtc.PeerConnection, error) {
|
||||
se := webrtc.SettingEngine{}
|
||||
// 把 pion 内部日志接到宿主 OnLog(真机诊断「为何 ICE 连不上」:候选对检查 / sendto 权限错误等),
|
||||
// 带上 sessionID 使宿主可按 session 聚合 / 整体复制。
|
||||
se.LoggerFactory = emitLoggerFactory{emit: log, sessionID: sessionID}
|
||||
// 候选收集用默认(全接口、双栈 IPv4+IPv6、不排除链路本地):桌面 pion 与 iOS WebKit 的原始互通
|
||||
// 实测可同内网 host↔host 直连(双 WiFi 网常经 IPv6)。早先为治 pion-on-iOS 加的接口 / 链路本地 /
|
||||
// IPv4-only 过滤纯属 iOS 定向,iOS 已退回 JS、不再用本引擎,那些过滤反令桌面侧 IPv6 host 对被裁掉、
|
||||
// 退中继(重大回归),故移除。
|
||||
se.SetSCTPMaxReceiveBufferSize(sctpReceiveBuffer)
|
||||
// 解析对端的 mDNS(.local)候选。iOS / Safari 出于隐私只播 mDNS host 候选;pion 默认不
|
||||
// 解析,便无法与之成 host↔host、退而走 srflx/relay 慢路径(实测 mac→iOS 落到 relay↔srflx、
|
||||
@@ -187,6 +195,7 @@ func (s *session) streamFile() {
|
||||
_ = s.dc.SendText(string(doneB))
|
||||
s.waitDrained()
|
||||
s.waitAck(s.fileSize)
|
||||
s.emitProgressNow()
|
||||
s.setState("completed")
|
||||
}
|
||||
|
||||
@@ -288,6 +297,7 @@ func (s *session) finalize() {
|
||||
s.out = nil
|
||||
s.eng.ev.OnSaved(s.id, final)
|
||||
}
|
||||
s.emitProgressNow()
|
||||
s.setState("completed")
|
||||
}
|
||||
|
||||
@@ -352,21 +362,42 @@ func (s *session) emitSignal(toPeer string, sdp *webrtc.SessionDescription, cand
|
||||
|
||||
// --- 进度 / 状态 / 收尾 ---
|
||||
|
||||
func (s *session) emitProgress() {
|
||||
var b int64
|
||||
// progressBytes 计算当前应上报的已传字节:发送端优先用接收端 ack 追平值(无 ack 时回退本地
|
||||
// 已交付估计),接收端用已写盘字节。
|
||||
func (s *session) progressBytes() int64 {
|
||||
if s.role == roleSender {
|
||||
if a := s.acked.Load(); a > 0 {
|
||||
b = min(s.fileSize, a)
|
||||
} else {
|
||||
b = s.bytesSent.Load() - int64(s.dc.BufferedAmount())
|
||||
if b < 0 {
|
||||
b = 0
|
||||
}
|
||||
return min(s.fileSize, a)
|
||||
}
|
||||
} else {
|
||||
b = s.received.Load()
|
||||
b := s.bytesSent.Load() - int64(s.dc.BufferedAmount())
|
||||
if b < 0 {
|
||||
return 0
|
||||
}
|
||||
return b
|
||||
}
|
||||
s.eng.ev.OnProgress(s.id, b)
|
||||
return s.received.Load()
|
||||
}
|
||||
|
||||
// emitProgress 节流上报:按 progressEmitThrottleMs 限频(~10Hz),避免高吞吐下每片一回调把宿主
|
||||
// 主线程打满(iOS 上经桥 evaluateJavaScript 数百次/秒 → UI 无响应)。CompareAndSwap 保证多 goroutine
|
||||
// (发送循环 / 接收 OnMessage / ack)并发下单发。终态的最终值由 emitProgressNow 强发,不被节流吞掉。
|
||||
func (s *session) emitProgress() {
|
||||
now := time.Now().UnixNano()
|
||||
last := s.lastEmitNs.Load()
|
||||
if now-last < int64(progressEmitThrottleMs)*int64(time.Millisecond) {
|
||||
return
|
||||
}
|
||||
if !s.lastEmitNs.CompareAndSwap(last, now) {
|
||||
return
|
||||
}
|
||||
s.eng.ev.OnProgress(s.id, s.progressBytes())
|
||||
}
|
||||
|
||||
// emitProgressNow 无视节流强发一次:收尾时确保最终字节数到达宿主(否则末次进度可能被节流吞掉,
|
||||
// 进度文案停在 99%)。
|
||||
func (s *session) emitProgressNow() {
|
||||
s.lastEmitNs.Store(time.Now().UnixNano())
|
||||
s.eng.ev.OnProgress(s.id, s.progressBytes())
|
||||
}
|
||||
|
||||
// waitBufferLow 等本地 SCTP 缓冲落到 threshold 以下:靠 OnBufferedAmountLow 事件唤醒,
|
||||
|
||||
@@ -28,6 +28,10 @@ const (
|
||||
lowWatermark = 4 * 1024 * 1024
|
||||
// 接收端把已收字节回传发送端的节流间隔(毫秒),与 web 引擎 ACK_INTERVAL_MS 一致。
|
||||
ackIntervalMs = 200
|
||||
// 进度回调上报的节流间隔(毫秒):高吞吐下每片一回调会把宿主主线程打满——iOS 上每片
|
||||
// 都经桥 evaluateJavaScript,数百次/秒会令 UI 无响应(gomobile 集成实测)。限到 ~10Hz,
|
||||
// 终态另由 emitProgressNow 强发最终值。与 web 引擎 store push 的 10Hz 节流同量级。
|
||||
progressEmitThrottleMs = 100
|
||||
// 发送端等接收端 ack 追平总量的上限(秒):超时仍照常收尾,避免 ack 丢失致悬挂。
|
||||
ackCompleteTimeoutSec = 30
|
||||
// pion 接收窗口:远高于 WebKit 写死的 256KB,使 pion 作接收端不被 rwnd × RTT 掐死
|
||||
|
||||
+1
-1
@@ -7,6 +7,7 @@ require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
|
||||
github.com/ebitengine/purego v0.10.1
|
||||
github.com/pion/ice/v4 v4.2.7
|
||||
github.com/pion/logging v0.2.4
|
||||
github.com/pion/webrtc/v4 v4.2.15
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
@@ -33,7 +34,6 @@ require (
|
||||
github.com/pion/datachannel v1.6.0 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.4 // indirect
|
||||
github.com/pion/interceptor v0.1.45 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.16 // indirect
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiFetch } from "../../net/api";
|
||||
import { getICEServers } from "./iceServers";
|
||||
import { isDesktop } from "../../net/desktop";
|
||||
import { isIOSShell } from "../../net/ios";
|
||||
import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store";
|
||||
import { deliverIncoming, openIncomingSink, type IncomingSink } from "./incomingSink";
|
||||
import {
|
||||
@@ -10,6 +11,13 @@ import {
|
||||
nativeStartIncoming,
|
||||
nativeStartOutgoing,
|
||||
} from "./p2pNative";
|
||||
import {
|
||||
nativeIosCleanup,
|
||||
nativeIosHandleSignal,
|
||||
nativeIosHasPeer,
|
||||
nativeIosStartIncoming,
|
||||
nativeIosStartOutgoing,
|
||||
} from "./p2pIos";
|
||||
import type { FileSource } from "./source";
|
||||
|
||||
// WebRTC client wired to the brief §2 invariants:
|
||||
@@ -45,6 +53,13 @@ const ACK_INTERVAL_MS = 200;
|
||||
// SSE 也会独立完成),避免 ack 丢失 / 接收端异常致发送端悬挂在"完成中"。
|
||||
const ACK_COMPLETE_TIMEOUT_MS = 30_000;
|
||||
|
||||
// iOS 原生数据面总开关。Swift 侧引擎已从 gomobile/pion 换成 libwebrtc(Safari 同款 RTCPeerConnection,
|
||||
// 与 iOS Network framework 原生集成)——逃离 WKWebView 写死的 256KB SCTP 接收窗 + 渲染器节流。JS 路由 /
|
||||
// 桥协议 / 线协议一字不变(见 p2pIos.ts、net/ios.ts),仅原生后端实现替换(见 ios 端 LibWebRtcEngine.swift)。
|
||||
// 早先 pion-on-iOS 因 Go raw socket 不与 iOS 网络栈集成而真机不可靠(连接时好时坏 / 中途断流),已废弃。
|
||||
// 桌面原生数据面仍走 pion(isDesktop 分支,P1 稳定),不受此开关影响。
|
||||
const IOS_NATIVE = true;
|
||||
|
||||
export interface FileMeta
|
||||
{
|
||||
name: string;
|
||||
@@ -897,8 +912,9 @@ const p2pSessions = new Map<string, Session>();
|
||||
|
||||
export function p2pHandleSignal(from: string, payload: SignalPayload): void
|
||||
{
|
||||
// 桌面:该对端的会话在原生后端(Go/pion)→ 把入站信令转给它。
|
||||
// 桌面 / iOS:该对端的会话在原生后端(Go/pion)→ 把入站信令转给它。
|
||||
if (nativeHasPeer(from)) { nativeHandleSignal(from, payload); return; }
|
||||
if (IOS_NATIVE && nativeIosHasPeer(from)) { nativeIosHandleSignal(from, payload); return; }
|
||||
const sess = p2pSessions.get(from);
|
||||
if (!sess)
|
||||
{
|
||||
@@ -912,6 +928,7 @@ export function p2pHandleSignal(from: string, payload: SignalPayload): void
|
||||
export function p2pCleanup(peerName: string): void
|
||||
{
|
||||
if (nativeHasPeer(peerName)) { nativeCleanup(peerName); return; }
|
||||
if (IOS_NATIVE && nativeIosHasPeer(peerName)) { nativeIosCleanup(peerName); return; }
|
||||
const sess = p2pSessions.get(peerName);
|
||||
if (sess) { sess.cancel(); }
|
||||
p2pSessions.delete(peerName);
|
||||
@@ -954,6 +971,12 @@ export function p2pStartOutgoing(
|
||||
{
|
||||
return nativeStartOutgoing(sessionId, receiverName, src.path, src.size);
|
||||
}
|
||||
// iOS 无头壳:源带 cdrop-file://<id> 引用(path)即走 libwebrtc 原生数据面(Swift 侧解析回沙盒
|
||||
// 绝对路径直接读盘)。IOS_NATIVE 关闭时回退下方 JS Session(WebKit WebRTC)。
|
||||
if (IOS_NATIVE && isIOSShell() && src.path)
|
||||
{
|
||||
return nativeIosStartOutgoing(sessionId, receiverName, src.path, src.size);
|
||||
}
|
||||
const sess = new Session(sessionId, receiverName, "sender");
|
||||
p2pSessions.set(receiverName, sess);
|
||||
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
|
||||
@@ -977,6 +1000,12 @@ export function p2pStartIncoming(sessionId: string, senderName: string): P2PSess
|
||||
{
|
||||
return nativeStartIncoming(sessionId, senderName);
|
||||
}
|
||||
// iOS 无头壳:接收端走 libwebrtc 原生数据面(Swift 直接写盘,无 base64 桥 / 无 OPFS)。
|
||||
// IOS_NATIVE 关闭时回退下方 JS Session(WebKit WebRTC,接收端流式 OPFS)。
|
||||
if (IOS_NATIVE && isIOSShell())
|
||||
{
|
||||
return nativeIosStartIncoming(sessionId, senderName);
|
||||
}
|
||||
const sess = new Session(sessionId, senderName, "receiver");
|
||||
p2pSessions.set(senderName, sess);
|
||||
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
// p2pIos —— iOS 无头壳的原生数据面后端。实现与 p2p.ts 同形的 P2PSession 接口与四个入口,
|
||||
// 把 WebRTC 收发委派给 gomobile/pion 引擎(经 cdropEngine 桥),逃离 WKWebView 的 256KB rwnd
|
||||
// 与渲染器节流(见 desktop/NATIVE-TRANSFER.md §7——iOS 与桌面共用同一份 Go engine 包)。
|
||||
//
|
||||
// 与桌面 p2pNative.ts 逐行对称,差别仅在桥:桌面经 Wails(window.go + EventsOn),iOS 经
|
||||
// net/ios.ts 的 callNative(RPC)+ onNativeEvent(事件)。分工一致:Go 引擎只跑纯 WebRTC +
|
||||
// 文件 I/O;一切 HTTP 在此发——出站信令 POST /api/hub/signal、状态机 POST(/p2p 于 connected·
|
||||
// 发送端、/done 于 completed·接收端、/fail 于 failed)。引擎经 p2p* 事件把进度 / 状态 / 出站
|
||||
// 信令 / 落盘路径反向回来。
|
||||
//
|
||||
// p2p.ts 在 iOS 壳按对端把这些会话登记到本后端(见其四个入口的分支),故 transfer.ts /
|
||||
// hub.ts 一字不改。
|
||||
import { apiFetch } from "../../net/api";
|
||||
import {
|
||||
nativeP2PCancel,
|
||||
nativeP2PHandleSignal,
|
||||
nativeP2PStartIncoming,
|
||||
nativeP2PStartOutgoing,
|
||||
subscribeNativeP2P,
|
||||
} from "../../net/ios";
|
||||
import { useAppStore, type TransferPhase } from "../../store";
|
||||
import { getICEServers } from "./iceServers";
|
||||
import type { P2PProgressEvent, P2PSession, P2PState } from "./p2p";
|
||||
|
||||
interface NativeSess
|
||||
{
|
||||
sessionId: string;
|
||||
peerName: string;
|
||||
role: "sender" | "receiver";
|
||||
state: P2PState;
|
||||
bytes: number;
|
||||
total: number;
|
||||
stateListeners: Set<(s: P2PState) => void>;
|
||||
progressListeners: Set<(e: P2PProgressEvent) => void>;
|
||||
p2pPosted: boolean;
|
||||
// 信令串行链:seed 为 StartOutgoing/Incoming 的桥调用(确保 Go 侧会话已建),其后每条入站
|
||||
// 信令串到链尾、按到达顺序投给 Go——pion 在 SetRemoteDescription 前 AddICECandidate 会报错,
|
||||
// 故 offer/answer 必须先于其后 trickle 的 ICE 候选。等价于桌面 p2pNative 的 chain。
|
||||
chain: Promise<void>;
|
||||
}
|
||||
|
||||
const sessionsById = new Map<string, NativeSess>();
|
||||
const idByPeer = new Map<string, string>();
|
||||
let subscribed = false;
|
||||
|
||||
// ensureSubscribed 懒注册全局 p2p* 事件监听(仅一次),据 sessionId 分发到各会话。
|
||||
function ensureSubscribed(): void
|
||||
{
|
||||
if (subscribed) { return; }
|
||||
subscribed = true;
|
||||
subscribeNativeP2P({
|
||||
onProgress: (sid, bytes) =>
|
||||
{
|
||||
const s = sessionsById.get(sid);
|
||||
if (!s) { return; }
|
||||
s.bytes = bytes;
|
||||
const total = s.total || sessionFileSize(sid);
|
||||
for (const cb of s.progressListeners) { cb({ bytes, total }); }
|
||||
pushStoreBytes(sid, bytes);
|
||||
},
|
||||
onState: (sid, state) =>
|
||||
{
|
||||
const s = sessionsById.get(sid);
|
||||
if (!s) { return; }
|
||||
applyState(s, state as P2PState);
|
||||
},
|
||||
onSignal: (_sid, toPeer, payloadJSON) =>
|
||||
{
|
||||
// 各信令独立 POST、不串行——串行会让一条慢 POST 头阻塞其后所有 trickle 候选,在弱网
|
||||
// 下致 ICE 无法在 30s 内连通 → 退中继(桌面实测「等待对方接受」回退的同款坑)。乱序到达
|
||||
// 对端只触发对端已 swallow 的 addIceCandidate 告警,无害。
|
||||
let payload: unknown;
|
||||
try { payload = JSON.parse(payloadJSON); }
|
||||
catch { return; }
|
||||
void apiFetch("/api/hub/signal", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ to: toPeer, payload }),
|
||||
}).then((r) =>
|
||||
{
|
||||
if (!r.ok && r.status !== 410)
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("ios native p2p signal HTTP", r.status);
|
||||
}
|
||||
}).catch(() => { /* 信令尽力而为;丢失由 ICE 超时兜底 */ });
|
||||
},
|
||||
onSaved: (_sid, _path) =>
|
||||
{
|
||||
// iOS 落盘成功由原生侧(DownloadManager + transferDone)告知用户,此处无需额外提示。
|
||||
},
|
||||
onIcePair: (sid, local, remote) =>
|
||||
{
|
||||
setIcePair(sid, local, remote);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// setIcePair 把 pion 选中的候选对填进记录的 iceStats,使调试面板能展示「实际走哪条路径」。
|
||||
// 候选分类计数 pion 未细分,留空(同桌面 p2pNative)。
|
||||
function setIcePair(sessionId: string, local: string, remote: string): void
|
||||
{
|
||||
const store = useAppStore.getState();
|
||||
const cur = store.activeTransfers[sessionId];
|
||||
if (!cur) { return; }
|
||||
const empty = { host: 0, mdns: 0, srflx: 0, prflx: 0, relay: 0 };
|
||||
store.upsertTransfer({
|
||||
...cur,
|
||||
iceStats: {
|
||||
gathering: "complete",
|
||||
connection: "connected",
|
||||
candidates: { local: { ...empty }, remote: { ...empty } },
|
||||
selectedPair: { local, remote },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sessionFileSize(sessionId: string): number
|
||||
{
|
||||
return useAppStore.getState().activeTransfers[sessionId]?.fileSize ?? 0;
|
||||
}
|
||||
|
||||
// applyState 把引擎状态落到监听者 + store,并发起对应的状态机 POST(HTTP 留 JS)。
|
||||
function applyState(s: NativeSess, state: P2PState): void
|
||||
{
|
||||
if (s.state === state) { return; }
|
||||
s.state = state;
|
||||
for (const cb of s.stateListeners) { cb(state); }
|
||||
updateStoreState(s.sessionId, state);
|
||||
|
||||
if (state === "connected")
|
||||
{
|
||||
// 推进 phase(否则 UI 状态停在初始 phase、且 bytes/速度文案被抑制)。
|
||||
setPhase(s.sessionId, "ice_connected");
|
||||
// 发送端在通道就绪时把传输标记为 P2P_ACTIVE,使随后接收端的 /done 合法。
|
||||
if (s.role === "sender" && !s.p2pPosted)
|
||||
{
|
||||
s.p2pPosted = true;
|
||||
markP2PActive(s.sessionId);
|
||||
}
|
||||
}
|
||||
else if (state === "completed")
|
||||
{
|
||||
// /done 由接收端权威发出;发送端只落本地终态(updateStoreState 已置 DONE)。
|
||||
if (s.role === "receiver") { markServerDone(s.sessionId, s.bytes); }
|
||||
}
|
||||
else if (state === "failed")
|
||||
{
|
||||
markServerFail(s.sessionId, "native_p2p_failed");
|
||||
}
|
||||
}
|
||||
|
||||
// updateStoreState 与 p2p.ts 同名函数等价(此处复制以免与 p2p.ts 形成运行时循环依赖)。
|
||||
function updateStoreState(sessionId: string, p2pState: P2PState): void
|
||||
{
|
||||
const store = useAppStore.getState();
|
||||
const cur = store.activeTransfers[sessionId];
|
||||
if (!cur) { return; }
|
||||
if (p2pState === "connected") { store.upsertTransfer({ ...cur, state: "P2P_ACTIVE" }); }
|
||||
else if (p2pState === "completed") { store.completeTransfer(sessionId, "DONE"); }
|
||||
else if (p2pState === "failed") { store.completeTransfer(sessionId, "FAILED"); }
|
||||
// closed 不主动移 history:交由上层 / 中继流程决定终态(同 p2p.ts)。
|
||||
}
|
||||
|
||||
function pushStoreBytes(sessionId: string, bytes: number): void
|
||||
{
|
||||
const store = useAppStore.getState();
|
||||
const cur = store.activeTransfers[sessionId];
|
||||
if (!cur) { return; }
|
||||
const grew = bytes > (cur.bytesTransferred ?? 0);
|
||||
store.upsertTransfer({
|
||||
...cur,
|
||||
// 字节流动即「传输中」:FLOW_PHASES 据此放开 bytes/速度文案,状态文案也随之正确。
|
||||
phase: cur.phase === "completing" ? cur.phase : "transferring",
|
||||
bytesTransferred: bytes,
|
||||
lastProgressAt: grew ? Date.now() : cur.lastProgressAt,
|
||||
});
|
||||
}
|
||||
|
||||
// setPhase 更新记录的 phase(驱动 UI 状态文案 + bytes/速度文案的可见性);同值跳过。
|
||||
function setPhase(sessionId: string, phase: TransferPhase): void
|
||||
{
|
||||
const store = useAppStore.getState();
|
||||
const cur = store.activeTransfers[sessionId];
|
||||
if (!cur || cur.phase === phase) { return; }
|
||||
store.upsertTransfer({ ...cur, phase });
|
||||
}
|
||||
|
||||
function markP2PActive(sessionId: string): void
|
||||
{
|
||||
void apiFetch(`/api/transfer/${sessionId}/p2p`, { method: "POST" }).catch(() => { /* 409 无碍 */ });
|
||||
}
|
||||
|
||||
function markServerDone(sessionId: string, bytes: number): void
|
||||
{
|
||||
void apiFetch(`/api/transfer/${sessionId}/done`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bytes_transferred: bytes }),
|
||||
}).catch(() => { /* 接收端权威 /done;失败由 SSE 终态兜底 */ });
|
||||
}
|
||||
|
||||
function markServerFail(sessionId: string, reason: string): void
|
||||
{
|
||||
void apiFetch(`/api/transfer/${sessionId}/fail`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
}).catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
function makeSession(s: NativeSess): P2PSession
|
||||
{
|
||||
return {
|
||||
sessionId: s.sessionId,
|
||||
peerName: s.peerName,
|
||||
get state() { return s.state; },
|
||||
onState: (cb) =>
|
||||
{
|
||||
s.stateListeners.add(cb);
|
||||
return () => s.stateListeners.delete(cb);
|
||||
},
|
||||
onProgress: (cb) =>
|
||||
{
|
||||
s.progressListeners.add(cb);
|
||||
return () => s.progressListeners.delete(cb);
|
||||
},
|
||||
cancel: () => { nativeIosCleanup(s.peerName); },
|
||||
};
|
||||
}
|
||||
|
||||
function register(s: NativeSess): void
|
||||
{
|
||||
sessionsById.set(s.sessionId, s);
|
||||
idByPeer.set(s.peerName, s.sessionId);
|
||||
}
|
||||
|
||||
// nativeIosStartOutgoing 经 Go 引擎发送 filePath(cdrop-file://<id> 引用,原生解析回沙盒绝对
|
||||
// 路径)到 peerName。total 用于进度事件的分母(store 的 bytesTransferred 仍是 UI 真值源)。
|
||||
export function nativeIosStartOutgoing(
|
||||
sessionId: string, peerName: string, filePath: string, total: number,
|
||||
): P2PSession
|
||||
{
|
||||
ensureSubscribed();
|
||||
const s: NativeSess = {
|
||||
sessionId, peerName, role: "sender", state: "connecting", bytes: 0, total,
|
||||
stateListeners: new Set(), progressListeners: new Set(), p2pPosted: false,
|
||||
chain: Promise.resolve(),
|
||||
};
|
||||
register(s);
|
||||
// 原生引擎即刻开始收集候选 + 连通性检查;显式推进 phase,避免 UI 停在 initiate 设的
|
||||
// 「等待对端接受」(waiting_accept)误导——原生后端不像 JS Session 那样发中间 phase。
|
||||
setPhase(sessionId, "ice_checking");
|
||||
const ice = JSON.stringify(getICEServers());
|
||||
// 用 StartOutgoing 的桥调用作信令链 seed:入站 answer / ICE 须等会话建好再投。
|
||||
s.chain = nativeP2PStartOutgoing(sessionId, peerName, filePath, ice).catch((e) =>
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("ios native start outgoing failed", e);
|
||||
applyState(s, "failed");
|
||||
});
|
||||
return makeSession(s);
|
||||
}
|
||||
|
||||
export function nativeIosStartIncoming(sessionId: string, senderName: string): P2PSession
|
||||
{
|
||||
ensureSubscribed();
|
||||
const s: NativeSess = {
|
||||
sessionId, peerName: senderName, role: "receiver", state: "connecting", bytes: 0, total: 0,
|
||||
stateListeners: new Set(), progressListeners: new Set(), p2pPosted: false,
|
||||
chain: Promise.resolve(),
|
||||
};
|
||||
register(s);
|
||||
setPhase(sessionId, "ice_checking");
|
||||
const ice = JSON.stringify(getICEServers());
|
||||
// 用 StartIncoming 的桥调用作信令链 seed:入站 offer / ICE 须等会话建好再投。
|
||||
s.chain = nativeP2PStartIncoming(sessionId, senderName, ice).catch((e) =>
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("ios native start incoming failed", e);
|
||||
applyState(s, "failed");
|
||||
});
|
||||
return makeSession(s);
|
||||
}
|
||||
|
||||
export function nativeIosHandleSignal(fromPeer: string, payload: unknown): void
|
||||
{
|
||||
const payloadJSON = JSON.stringify(payload);
|
||||
const sid = idByPeer.get(fromPeer);
|
||||
const s = sid ? sessionsById.get(sid) : undefined;
|
||||
if (!s)
|
||||
{
|
||||
// 无登记会话(极少见):直接尽力转发。
|
||||
void nativeP2PHandleSignal(fromPeer, payloadJSON).catch(() => { /* Go 会报无会话 */ });
|
||||
return;
|
||||
}
|
||||
// 串到会话信令链尾:等会话建好、且按到达顺序逐条投给 Go(offer/answer 先于 ICE 候选)。
|
||||
s.chain = s.chain.then(() => nativeP2PHandleSignal(fromPeer, payloadJSON)).catch((e) =>
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("ios native handle signal failed", e);
|
||||
});
|
||||
}
|
||||
|
||||
export function nativeIosCleanup(peerName: string): void
|
||||
{
|
||||
const sid = idByPeer.get(peerName);
|
||||
if (!sid) { return; }
|
||||
idByPeer.delete(peerName);
|
||||
const s = sessionsById.get(sid);
|
||||
sessionsById.delete(sid);
|
||||
void nativeP2PCancel(sid);
|
||||
if (s && s.state !== "completed" && s.state !== "failed")
|
||||
{
|
||||
s.state = "closed";
|
||||
for (const cb of s.stateListeners) { cb("closed"); }
|
||||
}
|
||||
}
|
||||
|
||||
// nativeIosHasPeer 供 p2p.ts 判断某对端的信令 / 清理该路由到 iOS 原生后端还是 JS 后端。
|
||||
export function nativeIosHasPeer(peerName: string): boolean
|
||||
{
|
||||
return idByPeer.has(peerName);
|
||||
}
|
||||
@@ -248,6 +248,9 @@ export function nativeStartOutgoing(
|
||||
chain: Promise.resolve(),
|
||||
};
|
||||
register(s);
|
||||
// 原生引擎即刻收集候选 + 连通性检查;显式推进 phase,避免 UI 停在 initiate 设的
|
||||
// 「等待对端接受」——原生后端不像 JS Session 那样发中间 phase。
|
||||
setPhase(sessionId, "ice_checking");
|
||||
const ice = JSON.stringify(getICEServers());
|
||||
// 用 StartOutgoing 的桥调用作信令链 seed:入站 answer / ICE 须等会话建好再投。
|
||||
s.chain = nativeP2PStartOutgoing(sessionId, peerName, filePath, ice).catch((e) =>
|
||||
@@ -268,6 +271,7 @@ export function nativeStartIncoming(sessionId: string, senderName: string): P2PS
|
||||
chain: Promise.resolve(),
|
||||
};
|
||||
register(s);
|
||||
setPhase(sessionId, "ice_checking");
|
||||
const ice = JSON.stringify(getICEServers());
|
||||
// 用 StartIncoming 的桥调用作信令链 seed:入站 offer / ICE 须等会话建好再投。
|
||||
s.chain = nativeP2PStartIncoming(sessionId, senderName, ice).catch((e) =>
|
||||
|
||||
@@ -214,6 +214,10 @@ export function bridgeFileSource(url: string, name: string, size: number, type:
|
||||
name,
|
||||
size,
|
||||
type: type || "application/octet-stream",
|
||||
// path 带 cdrop-file://<id> 引用:iOS 原生数据面(gomobile/pion)据此让原生侧解析回沙盒
|
||||
// 绝对路径、由 Go 直接读盘发送(见 p2pIos.ts / EngineController.p2pStartOutgoing)。JS 收发
|
||||
// 路径不读 path,故对回退到 JS 引擎的传输无影响(与桌面 nativeFileSource 同范式)。
|
||||
path: url,
|
||||
async slice(start, end)
|
||||
{
|
||||
const total = end - start;
|
||||
@@ -233,3 +237,83 @@ export function bridgeFileSource(url: string, name: string, size: number, type:
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 原生 P2P 数据面桥(仅 iOS 无头壳) ────────────────────────────────────────
|
||||
//
|
||||
// 与桌面 desktop.ts 的 P2P 段同形,把 WebRTC 收发委派给 gomobile/pion 引擎(经 cdropEngine
|
||||
// 桥),逃离 WKWebView 的 256KB rwnd 与渲染器节流(见 desktop/NATIVE-TRANSFER.md §7)。分工同桌面:
|
||||
// Go 引擎只跑纯 WebRTC + 文件 I/O;一切 HTTP(出站信令 / 状态机 POST)留 JS(见 p2pIos.ts)。
|
||||
// 四个入口走 callNative(RPC,原生回 resolve);进度 / 状态 / 出站信令 / 落盘 / 候选对经
|
||||
// onNativeEvent 反向回来。原生侧契约见 ios/CDrop/Sources/Engine/EngineController.swift。
|
||||
|
||||
// nativeP2PStartOutgoing:发起原生发送。filePath 为 cdrop-file://<id> 引用,原生解析回沙盒
|
||||
// 绝对路径后交 Go 直接读盘(不经 base64 桥 / 不整文件进内存)。
|
||||
export async function nativeP2PStartOutgoing(
|
||||
sessionId: string, peerName: string, filePath: string, iceServersJSON: string,
|
||||
): Promise<void>
|
||||
{
|
||||
await callNative<void>("p2pStartOutgoing", { sessionId, peerName, filePath, iceServersJSON });
|
||||
}
|
||||
|
||||
export async function nativeP2PStartIncoming(
|
||||
sessionId: string, peerName: string, iceServersJSON: string,
|
||||
): Promise<void>
|
||||
{
|
||||
await callNative<void>("p2pStartIncoming", { sessionId, peerName, iceServersJSON });
|
||||
}
|
||||
|
||||
export async function nativeP2PHandleSignal(fromPeer: string, payloadJSON: string): Promise<void>
|
||||
{
|
||||
await callNative<void>("p2pHandleSignal", { fromPeer, payloadJSON });
|
||||
}
|
||||
|
||||
export async function nativeP2PCancel(sessionId: string): Promise<void>
|
||||
{
|
||||
if (!handler()) { return; }
|
||||
try { await callNative<void>("p2pCancel", { sessionId }); }
|
||||
catch { /* 取消路径吞错 */ }
|
||||
}
|
||||
|
||||
// 原生 P2P 事件回调集合(引擎经 __cdropEngineEvent 反向通知,与桌面 NativeP2PHandlers 同形)。
|
||||
export interface NativeP2PHandlers
|
||||
{
|
||||
onProgress: (sessionId: string, bytes: number) => void;
|
||||
onState: (sessionId: string, state: string) => void;
|
||||
onSignal: (sessionId: string, toPeer: string, payloadJSON: string) => void;
|
||||
onSaved: (sessionId: string, path: string) => void;
|
||||
onIcePair: (sessionId: string, local: string, remote: string) => void;
|
||||
}
|
||||
|
||||
// subscribeNativeP2P 订阅 p2p* 事件并分发;返回取消订阅函数。非 iOS 壳里 onNativeEvent 仍可
|
||||
// 调用(只是原生永不派发),保持调用点无需分支。
|
||||
export function subscribeNativeP2P(h: NativeP2PHandlers): () => void
|
||||
{
|
||||
const offs = [
|
||||
onNativeEvent("p2pProgress", (pl) =>
|
||||
{
|
||||
const p = pl as { sessionId: string; bytes: number };
|
||||
h.onProgress(p.sessionId, p.bytes);
|
||||
}),
|
||||
onNativeEvent("p2pState", (pl) =>
|
||||
{
|
||||
const p = pl as { sessionId: string; state: string };
|
||||
h.onState(p.sessionId, p.state);
|
||||
}),
|
||||
onNativeEvent("p2pSignal", (pl) =>
|
||||
{
|
||||
const p = pl as { sessionId: string; to: string; payload: string };
|
||||
h.onSignal(p.sessionId, p.to, p.payload);
|
||||
}),
|
||||
onNativeEvent("p2pSaved", (pl) =>
|
||||
{
|
||||
const p = pl as { sessionId: string; path: string };
|
||||
h.onSaved(p.sessionId, p.path);
|
||||
}),
|
||||
onNativeEvent("p2pIcePair", (pl) =>
|
||||
{
|
||||
const p = pl as { sessionId: string; local: string; remote: string };
|
||||
h.onIcePair(p.sessionId, p.local, p.remote);
|
||||
}),
|
||||
];
|
||||
return () => { for (const off of offs) { off(); } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user