把桌面 P2P 数据面从 WebView 内 JS WebRTC 移到 Go 进程的 pion,逃离 WebKit/WebView2 写死的 256KB SCTP 接收窗与隐藏窗口的渲染器节流。后端零改动、与现有客户端互通(线协议逐字节对齐)。真机实测原生↔原生 / 原生↔iOS(经有线)稳定 7-10MB/s,对比旧 JS 路径的 1.5MB/s 还塌缩是质变。设计与阶段见 desktop/NATIVE-TRANSFER.md。
- engine 包(纯 Go,仅 pion+stdlib、零 Wails 依赖,供将来 gomobile 复用于 iOS):pion 收发,逐字节对齐 web 引擎线协议(DataChannel cdrop-file ordered、文本帧 meta/done/ack、二进制分片、信令 {type,sdp?,candidate?});SetSCTPMaxReceiveBufferSize 顶到 4MB;mDNS QueryOnly 解析对端 .local 候选实现同内网 host↔host 直连;接收端直接写盘(去 base64 桥 / OPFS);进程内端到端测试 + 诊断日志(候选/ICE 态/选中对/发送停滞)。
- app.go:Bind P2PStartOutgoing / StartIncoming / HandleSignal / Cancel + 原生取文件 PickFileForSend + 中继回退读盘片 ReadFileSlice;引擎回调经 EventsEmit p2p:* 反向通知(进度/状态/信令/落盘路径/候选对)。
- JS p2pBackend 抽象(p2pNative.ts):桌面按对端把会话委派给 Go 引擎,transfer.ts / hub.ts 零改动;HTTP 全留 JS(出站信令 POST、状态机 /p2p·接收端 /done·/fail);监听 p2p:* 事件驱动 store、phase 与 iceStats。
- 发送取文件改原生路径:selectedFile 模型 File→FileSource(桌面带绝对 path → Go 直接读盘走原生;浏览器拖放无 path → 回退 JS)。Composer dropzone 在桌面打开原生文件对话框。
- 完成语义修复:接收端完成不立即拆连接(续发最终 ack、留 SSE 终态 Cancel 拆、60s 兜底、终态不可被后续 close 翻转)——修「iOS→mac 实际成功却被误标失败」。
- 剪贴板暂存路径修复:富文本经 Universal Clipboard 暂存为 .rtfd,pasteboard 纯文本表示有时取到该暂存路径——uploadClipboard 共享守卫拦截 + mac 原生读改从 RTF / RTFD 派生纯文本。
13 KiB
cdrop 桌面原生数据面(Option A)设计
目标:把桌面 P2P 数据面(WebRTC DataChannel 收发)从 WebView 的 JS 移到 Go 进程(
pion/webrtc),留在 Wails、不上 Tauri。 关联:desktop/PLAN.md(桌面总计划)、desktop/RESEARCH.md(调研)、web/src/features/transfer/{transfer,p2p,relay,incomingSink,source}.ts、吞吐专项诊断(task #38)。 状态:设计稿(2026-06-27)。Phase 0(止血)与 Phase 1(落地)见末尾阶段表。
0. 为什么是 Go/pion,而不是 Tauri
吞吐专项(#38)定位出两个根因,都活在 WebView 的 WebRTC 层:
- B2 渲染器节流:Chromium/WebView2 的 SCTP 处理线程活在渲染器进程内,窗口隐藏/遮挡/最小化触发 renderer backgrounding + 定时器节流 → SCTP 被饿。Windows host↔host 直连仍 100KB/s 即此。
- B1 接收窗口:WebKit 的 usrsctp 接收窗口写死 256KB(JS 改不动),
吞吐 ≈ rwnd / RTT,高 RTT 路径被掐。
Tauri 用的是同款系统 WebView(WebView2 / WKWebView),上述两条一个不变——换 Tauri 是把整个 desktop/platform/ Go 树重写成 Rust、零吞吐收益。真正的杠杆是「把数据面移出 WebView 跑原生」,而这在现有 Go 栈用 pion/webrtc 即可,无须 Tauri。
原生 Go/pion 一次性收口:
| 根因 | 留 WebView | 原生 Go/pion | 机理 |
|---|---|---|---|
| B2 渲染节流 | 旗标缓解、仍受启发式摆布 | 彻底消失 | WebRTC 不在渲染器里,窗口状态与网络栈无关——「网络不被渲染阻塞」的终极形态 |
| B1 桌面作接收端 | WebKit 写死 256KB | 可配 | SettingEngine.SetSCTPMaxReceiveBufferSize 设 1-16MB |
| mac srflx 非 host | WebKit mDNS 隐私替换 | 消失 | pion 默认用真实 IP 的 host 候选,不做 .local 混淆 |
| base64 过桥 | 收发都过 base64 桥 | 消失 | Go 直接读/写磁盘文件,无 OPFS / 混合 sink |
| 桌面↔桌面 | 双重受限 | 全速 | 两端原生大 rwnd + 直连 |
1. 范围
移到 Go: 仅 P2P 数据面(pion peerconnection / datachannel 收发、文件磁盘 I/O)及其信令(offer/answer/ICE)。
留在 JS(单实现、不动): 全部编排——transfer.ts 的 /initiate、30s 中继回退看门狗、离线对端 presence 等待、relay 收发、状态机 POST、取消、store/UI。relay 非吞吐瓶颈(~3MB/s,且本就过 Go 代理),Phase 1 不碰。
残差(Phase 1 不解): 桌面 → WebKit 接收端(iOS/Safari)在高 RTT 路径仍 256KB / RTT——留 Phase 2 striping。同内网低 RTT 路径下 256KB 非约束,不受影响。
2. 接缝:p2pBackend 抽象(核心,使 transfer.ts 零改动)
现状调用链:transfer.ts / hub.ts → p2pStartOutgoing(sessionId, peer, src) : P2PSession、p2pStartIncoming(sessionId, peer) : P2PSession、p2pHandleSignal(from, payload)、p2pCleanup(peer)。
引入同形后端接口:
interface P2pBackend
startOutgoing(sessionId, peerName, src) → P2PSession // P2PSession: onState / onProgress / cancel,与现有一致
startIncoming(sessionId, peerName) → P2PSession
handleSignal(fromPeer, payload) → void
cleanup(peerName) → void
selectBackend() → isDesktop() ? goBridgeBackend : jsWebrtcBackend // web / iOS 走 jsWebrtcBackend(现有 p2p.ts)
transfer.ts 的编排(看门狗 / presence / relay / state POST / cancel)全部不变——它只面对 P2PSession 事件接口;后端是 JS-WebRTC 还是 Go-bridge 对它透明。
3. Go 侧:p2pengine 包(pion)
Sender(sessionId, peerName, filePath, iceServers):
pc ← pion.NewPeerConnection(SettingEngine{ rwnd 大; host 候选真 IP; iceServers })
dc ← pc.CreateDataChannel("cdrop-file", ordered=true)
onOpen:
send JSON {type:meta, name, size, sha256?} // 与 p2p.ts 同帧
for chunk in readFileByPath(filePath, CHUNK): // Go 直接磁盘读,无桥、无 base64
backpressure: 等 dc.BufferedAmount 落水位
dc.Send(chunk) // 二进制帧
send JSON {type:done}
等 receiver ack 追平 size → 报 completed
onMessage(ack): 更新 ackedBytes → emit progress
POST /api/transfer/{id}/p2p(活跃); 完成不 POST /done(接收端权威)
Receiver(sessionId, peerName, iceServers):
pc ← pion.NewPeerConnection(SettingEngine{ rwnd 大; ... })
onDataChannel(dc):
onMessage(meta): 打开下载目录文件句柄(复用 download.go)
onMessage(chunk): 直接写盘; 节流 200ms 回 ack {type:ack, bytes}
onMessage(done): 立即回最终 ack; 关闭文件; POST /api/transfer/{id}/done
信令(Go own):pc.OnICECandidate → POST /api/hub/signal(带 a.token)
inbound offer/answer/ice ← JS hub 转发进来(见 §4)
ICE creds:Go 拉 /api/calls/credentials(带 a.token),与 JS 同源
关键配置: SetSCTPMaxReceiveBufferSize(大 rwnd);host 候选真 IP(pion 默认,无 mDNS);复用现有 Cloudflare TURN。
4. 桥接面(Wails Go↔JS)与「单 SSE」原则
JS → Go(bind 方法):
StartOutgoing(sessionId, peerName, filePath)、StartIncoming(sessionId, peerName)HandleSignal(sessionId, fromPeer, payloadJSON)← JS hub 把入站信令转发给 GoCancel(sessionId)← 取消 / 中继回退时 JS 通知 Go 拆 pion
Go → JS(Wails events): transfer:progress {sessionId, bytes}、transfer:state {sessionId, state}。
单 SSE 原则(重要): 设备只有一条 SSE(JS hub.ts,承载 presence + 信令 + 唤醒)。不让 Go 另开 SSE——第二条会污染 presence/在线态。故入站信令统一由 JS hub 收下,对 Go-owned 会话转发给 Go;出站信令由 Go 直接 POST(Go 有 a.token + oauth.go 刷新,本就是 API 调用的正确归属)。
5. 发送取文件:改用 Wails 原生路径
浏览器 <input type=file> / drag-drop 给的是沙箱 File(无磁盘路径、受 256MB 内存约束)。Go 要直接读盘,需绝对路径:
- 桌面发送改用 Wails 原生文件对话框 /
OnFileDrop(返回绝对路径)→ 传路径给 Go。 name/size由 Gostat出,回给 JS 供/initiate与 store 记录。- 这条是桌面前端的局部改动(
isDesktop()分支),收益:彻底脱离 WebView 内存、无桥。
6. 协议对齐清单(JS 引擎 ↔ Go 引擎必须逐项一致)
双实现的唯一契约是线协议。任何一项漂移都会让桌面↔web/iOS 互通断裂。建议抽出共享测试向量(meta/chunk/done/ack 的字节级样例)双侧回归。
- 信令:
POST /api/hub/signal {to, payload:{type:"offer"|"answer"|"ice", sdp?, candidate?}};入站经 SSE。 - DataChannel:名
"cdrop-file",ordered:true。 - 帧:
metaJSON{type,name,size,sha256?}/ 二进制 chunk /doneJSON /ackJSON{type,bytes}(接收端节流 200ms 回传累计已收字节,单调取大)。 - 状态机:
/api/transfer/{initiate,p2p,done,fail,cancel,fallback};完成由接收端 POST/done(权威),发送端不重复。 - 中继回退语义:JS 看门狗 30s 未
connected→POST /fallback并对 GoCancel(session);Go 拆 pion,relay 由 JS 接管(数据面回到 JS relay,符合「relay 留 JS」)。
7. iOS 数据面:经 gomobile 与桌面共用同一份 Go 引擎(U1,POC 闸)
决策(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)。 - FFI 边界对 cdrop 恰好够用:API(
StartOutgoing/HandleSignal/Cancel全字符串 + 回调接口OnProgress(int64)/OnState/OnSignal(json))完全合法;文件 chunk 的[]byte不过边界——Go 自读盘经 pion 内部发送,只 int + JSON 串过桥。 - rwnd 可配实锤:
SetSCTPMaxReceiveBufferSize(4MB)广告 4MB 接收窗,碾过 256KB;另有SetSCTPMinCwnd / SetSCTPCwndCAStep / EnableSCTPZeroChecksum等拥塞/延迟旋钮。 - 关 mDNS + 真 host 候选(
SetAnsweringMDNSEnabled(false)):从根上避开 srflx 退化;首次 LAN 连接弹一次本地网络权限(需NSLocalNetworkUsageDescription)。 - 体积 ~9-15MB(压缩),小于 libwebrtc;后台挂起需
beginBackgroundTask(iOS 通病、非缺陷)。 - 成本 ~1 周;成熟度 LOW-MEDIUM(无已知公开 pion+gomobile 生产案例——主要风险)。
架构对称性(统一的真正收益):iOS 变成与桌面同构——
- 桌面:Wails 壳(JS 编排 + UI)+ Go/pion 数据面(原生进程)。
- iOS:SwiftUI 壳(WKWebView 跑 JS 编排/hub/presence/messages + UI)+ Go/pion 数据面(gomobile xcframework)。
- 二者共用同一
p2pBackend抽象、同一桥接语义(单 SSE 留 JS hub、信令转发给 Go)、同一 wire protocol、同一 striping 实现。差别仅在原生壳与桥的 binding(Wails bind vs gomobile + WKScriptMessageHandler)。
U1 vs U2(备选)对照:
| U1:gomobile + pion(iOS 与桌面同引擎) | U2:iOS 用 libwebrtc.framework | |
|---|---|---|
| 统一 | 2 份引擎(JS + Go),原生侧一份实现 | 3 份(JS-browser / Go-pion / iOS-libwebrtc) |
| iOS rwnd | pion 4MB+(可配) | DcSCTP 5MB 默认(不可经 ObjC 配,但默认已够) |
| 高 RTT 吞吐 | 较弱(pion/sctp#218,~17-45Mbps@100-300ms,仍 10-20× 于 256KB);可经 cwnd 旋钮 + striping 补 | 更强(libwebrtc 成熟拥塞控制) |
| LAN 吞吐 | 优(177-218Mbps 实测) | 优(原生,去浏览器 IPC 开销) |
| 体积 | ~9-15MB | ~12-18MB(设备切片更大) |
| 成熟度/风险 | LOW-MEDIUM(gomobile 无生产先例、Xcode 版本偶断) | 成熟(stasel/livekit 预编包),但多一份栈、社区维护需 pin 版本 |
| 互通 | pion↔browser 经 pion CI 持续验证;iOS-gomobile 网络栈待 POC 验 | iOS-libwebrtc↔pion 无文档,需冒烟测(pion#2288 报抖动) |
裁决:选 U1,以 POC 为闸。 理由:桌面已 pion → iOS 同走 pion 保持引擎/特性/协议一致、避免第三份栈,最契合「统一」;性能在 cdrop 的局域网主场景两者皆优;高 RTT 的差距是 pion 全局问题(桌面同样吃),单点解决(cwnd 旋钮 / striping / 上游修 #218 / 就近 TURN)即全端受益,且 pion 高 RTT 仍 10-20× 于今日 WebKit。以可补的高 RTT 残差,换最大化的统一收益,符合用户方针。
统一在协议 + 实现(双层):web 无原生选项故 JS 引擎不可消;但桌面与 iOS 经 U1 收敛到同一份 Go 引擎。三端共享同一 wire protocol(§6),striping 作为协议扩展在「JS 引擎(web)」与「Go 引擎(桌面+iOS)」两处实现。
8. 阶段
| 阶段 | 内容 | 验收 |
|---|---|---|
| Phase 0(止血,临时) | C:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS 注入反节流旗标(--disable-renderer-backgrounding 等) |
Windows 隐藏/遮挡窗口传输不掉速 |
| 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 大幅改善;与桌面同引擎 |
| Phase 4(按需) | 共享协议 N 关联 striping(JS 引擎 for web;Go 引擎 for 桌面+iOS) | 解跨 NAT 高 RTT 残差(全端) |
9. 风险与缓解
- pion ↔ 浏览器互通:标准 SDP/DTLS/SCTP,pion 主用例,低风险;需验 pion DataChannel 在大 rwnd 下的实测吞吐。
- 双实现协议漂移:靠 §6 对齐清单 + 共享字节级测试向量双侧回归。
- Go 调 API 鉴权:Go 已持
a.token+oauth.go刷新,本就是 API 调用正确归属;信令/状态机 POST 直接带 Authorization。 - 前端发送路径分叉:
isDesktop()改用 Wails 原生路径——局部、有先例(net/desktop已有isDesktop()分流)。