# 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 接收窗口写死 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("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 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 把入站信令转发给 Go - `Cancel(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 原生路径 浏览器 `` / 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 拆 pion,relay 由 JS 接管(数据面回到 JS relay,符合「relay 留 JS」)。 --- ## 7. iOS 数据面:libwebrtc(U2,POC 翻案后定) > **⚠️ 决策反转(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 `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 维护者本人推荐 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 大幅改善;与桌面**同引擎** | > **进度(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 残差(全端) | --- ## 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()` 分流)。