From f1a00d128e51419fc1627ba1be5e88cd3c69d0d1 Mon Sep 17 00:00:00 2001 From: commilitia Date: Sun, 28 Jun 2026 19:32:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=9F=E7=94=9F=E6=95=B0=E6=8D=AE=E9=9D=A2?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E5=B1=82=EF=BC=9A=E6=A1=8C=E9=9D=A2=20engine?= =?UTF-8?q?=20=E8=BF=9B=E5=BA=A6=E8=8A=82=E6=B5=81=20/=20pion=20=E6=97=A5?= =?UTF-8?q?=E5=BF=97=20+=20iOS=20=E6=95=B0=E6=8D=AE=E9=9D=A2=E8=B7=AF?= =?UTF-8?q?=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 桌面 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 互通 --- desktop/NATIVE-TRANSFER.md | 23 +- desktop/engine/engine.go | 11 +- desktop/engine/engine_test.go | 2 +- desktop/engine/logging.go | 63 +++++ desktop/engine/session.go | 61 +++-- desktop/engine/wire.go | 4 + desktop/go.mod | 2 +- web/src/features/transfer/p2p.ts | 31 ++- web/src/features/transfer/p2pIos.ts | 324 +++++++++++++++++++++++++ web/src/features/transfer/p2pNative.ts | 4 + web/src/net/ios.ts | 84 +++++++ 11 files changed, 587 insertions(+), 22 deletions(-) create mode 100644 desktop/engine/logging.go create mode 100644 web/src/features/transfer/p2pIos.ts diff --git a/desktop/NATIVE-TRANSFER.md b/desktop/NATIVE-TRANSFER.md index f0c8fc5..ee59787 100644 --- a/desktop/NATIVE-TRANSFER.md +++ b/desktop/NATIVE-TRANSFER.md @@ -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 `(首次自动经 SPM 拉 `WebRTC.xcframework`,无 gomobile 预构建步骤)。 | **Phase 4(按需)** | 共享协议 **N 关联 striping**(JS 引擎 for web;Go 引擎 for 桌面+iOS) | 解跨 NAT 高 RTT 残差(全端) | --- diff --git a/desktop/engine/engine.go b/desktop/engine/engine.go index e92b2b0..16eb35a 100644 --- a/desktop/engine/engine.go +++ b/desktop/engine/engine.go @@ -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 } diff --git a/desktop/engine/engine_test.go b/desktop/engine/engine_test.go index e4c2ae0..e922f8d 100644 --- a/desktop/engine/engine_test.go +++ b/desktop/engine/engine_test.go @@ -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)、水位回压、 diff --git a/desktop/engine/logging.go b/desktop/engine/logging.go new file mode 100644 index 0000000..758c445 --- /dev/null +++ b/desktop/engine/logging.go @@ -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 ..." 起头,与 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...)) +} diff --git a/desktop/engine/session.go b/desktop/engine/session.go index 859ef29..498303d 100644 --- a/desktop/engine/session.go +++ b/desktop/engine/session.go @@ -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 事件唤醒, diff --git a/desktop/engine/wire.go b/desktop/engine/wire.go index 984bdb3..8c950ac 100644 --- a/desktop/engine/wire.go +++ b/desktop/engine/wire.go @@ -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 掐死 diff --git a/desktop/go.mod b/desktop/go.mod index 2db033a..2570218 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -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 diff --git a/web/src/features/transfer/p2p.ts b/web/src/features/transfer/p2p.ts index 4980b28..d4366a8 100644 --- a/web/src/features/transfer/p2p.ts +++ b/web/src/features/transfer/p2p.ts @@ -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(); 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:// 引用(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)); diff --git a/web/src/features/transfer/p2pIos.ts b/web/src/features/transfer/p2pIos.ts new file mode 100644 index 0000000..59a4437 --- /dev/null +++ b/web/src/features/transfer/p2pIos.ts @@ -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; +} + +const sessionsById = new Map(); +const idByPeer = new Map(); +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:// 引用,原生解析回沙盒绝对 +// 路径)到 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); +} diff --git a/web/src/features/transfer/p2pNative.ts b/web/src/features/transfer/p2pNative.ts index 006bff6..ae83a21 100644 --- a/web/src/features/transfer/p2pNative.ts +++ b/web/src/features/transfer/p2pNative.ts @@ -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) => diff --git a/web/src/net/ios.ts b/web/src/net/ios.ts index 1c5167f..9d2d951 100644 --- a/web/src/net/ios.ts +++ b/web/src/net/ios.ts @@ -214,6 +214,10 @@ export function bridgeFileSource(url: string, name: string, size: number, type: name, size, type: type || "application/octet-stream", + // path 带 cdrop-file:// 引用: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:// 引用,原生解析回沙盒 +// 绝对路径后交 Go 直接读盘(不经 base64 桥 / 不整文件进内存)。 +export async function nativeP2PStartOutgoing( + sessionId: string, peerName: string, filePath: string, iceServersJSON: string, +): Promise +{ + await callNative("p2pStartOutgoing", { sessionId, peerName, filePath, iceServersJSON }); +} + +export async function nativeP2PStartIncoming( + sessionId: string, peerName: string, iceServersJSON: string, +): Promise +{ + await callNative("p2pStartIncoming", { sessionId, peerName, iceServersJSON }); +} + +export async function nativeP2PHandleSignal(fromPeer: string, payloadJSON: string): Promise +{ + await callNative("p2pHandleSignal", { fromPeer, payloadJSON }); +} + +export async function nativeP2PCancel(sessionId: string): Promise +{ + if (!handler()) { return; } + try { await callNative("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(); } }; +}