Files
Commilitia-Drop/desktop/NATIVE-TRANSFER.md
T

199 lines
18 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.
# Commilitia Drop 桌面原生数据面(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("commilitia-drop-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**:名 `"commilitia-drop-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 数据面:libwebrtcU2POC 翻案后定)
> **⚠️ 决策反转(2026-06-28,真机 POC 证伪 U1**:下方 U1gomobile+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 `commilitia-drop-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 维护者本人推荐 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 大幅改善;与桌面**同引擎** |
> **进度(2026-06-28):Phase 2 POC 证伪 U1 → 转 U2libwebrtc);Phase 3U2 版)✅ 代码完成、模拟器编译 + 环回端到端测试绿,待真机/真网吞吐验。**
> - **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` recipegomobile 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 prodgated,浏览器/桌面零影响)或 `CDROP_ENGINE_URL` 指本地安全源(见 REALDEVICE §C);真机装机走 `just ios-device <udid>`(首次自动经 SPM 拉 `WebRTC.xcframework`,无 gomobile 预构建步骤)。
| **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()` 分流)。