Files
Commilitia-Drop/desktop/NATIVE-TRANSFER.md
T
admin ab57afdec2 桌面原生数据面:pion 数据通道取代 WebView WebRTC,逃离 256KB rwnd 与渲染器节流
把桌面 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 派生纯文本。
2026-06-27 23:49:54 +08:00

180 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 接收窗口写死 256KBJS 改不动),`吞吐 ≈ rwnd / RTT`,高 RTT 路径被掐。
**Tauri 用的是同款系统 WebViewWebView2 / 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 credsGo 拉 /api/calls/credentials(带 a.token),与 JS 同源
```
**关键配置:** `SetSCTPMaxReceiveBufferSize`(大 rwnd);host 候选真 IPpion 默认,无 mDNS);复用现有 Cloudflare TURN。
---
## 4. 桥接面(Wails Go↔JS)与「单 SSE」原则
**JS → Gobind 方法):**
- `StartOutgoing(sessionId, peerName, filePath)``StartIncoming(sessionId, peerName)`
- `HandleSignal(sessionId, fromPeer, payloadJSON)` ← JS hub 把入站信令转发给 Go
- `Cancel(sessionId)` ← 取消 / 中继回退时 JS 通知 Go 拆 pion
**Go → JSWails events):** `transfer:progress {sessionId, bytes}``transfer:state {sessionId, state}`
**单 SSE 原则(重要):** 设备只有**一条** SSEJS `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` 由 Go `stat` 出,回给 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`
- **帧**`meta` JSON `{type,name,size,sha256?}` / 二进制 chunk / `done` JSON / `ack` JSON `{type,bytes}`(接收端节流 200ms 回传累计已收字节,单调取大)。
- **状态机**`/api/transfer/{initiate,p2p,done,fail,cancel,fallback}`**完成由接收端 POST `/done`**(权威),发送端不重复。
- **中继回退语义**:JS 看门狗 30s 未 `connected``POST /fallback` 并对 Go `Cancel(session)`Go 拆 pionrelay 由 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 维护者本人推荐 gomobilediscussion#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 数据面(原生进程)。
- iOSSwiftUI 壳(WKWebView 跑 JS 编排/hub/presence/messages + UI+ Go/pion 数据面(gomobile xcframework)。
- 二者共用同一 `p2pBackend` 抽象、同一桥接语义(单 SSE 留 JS hub、信令转发给 Go)、同一 wire protocol、同一 striping 实现。差别仅在原生壳与桥的 bindingWails bind vs gomobile + WKScriptMessageHandler)。
**U1 vs U2(备选)对照:**
| | U1gomobile + pioniOS 与桌面同引擎) | U2iOS 用 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-MEDIUMgomobile 无生产先例、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 2iOS 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 摆脱 256KBiOS↔* 高 RTT 大幅改善;与桌面**同引擎** |
| **Phase 4(按需)** | 共享协议 **N 关联 striping**JS 引擎 for webGo 引擎 for 桌面+iOS | 解跨 NAT 高 RTT 残差(全端) |
---
## 9. 风险与缓解
- **pion ↔ 浏览器互通**:标准 SDP/DTLS/SCTPpion 主用例,低风险;**需验** pion DataChannel 在大 rwnd 下的实测吞吐。
- **双实现协议漂移**:靠 §6 对齐清单 + 共享字节级测试向量双侧回归。
- **Go 调 API 鉴权**Go 已持 `a.token` + `oauth.go` 刷新,本就是 API 调用正确归属;信令/状态机 POST 直接带 Authorization。
- **前端发送路径分叉**`isDesktop()` 改用 Wails 原生路径——局部、有先例(`net/desktop` 已有 `isDesktop()` 分流)。