桌面原生数据面: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 派生纯文本。
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// Package engine 是 cdrop 的原生数据面:用 pion/webrtc 在 Go 进程内完成 P2P 文件收发,
|
||||
// 取代 WebView 内 JS 引擎跑 WebRTC 的旧路径。动机见 desktop/NATIVE-TRANSFER.md——逃离
|
||||
// WebKit 写死的 256KB SCTP 接收窗 + 渲染器节流,并让桌面与 iOS(经 gomobile)共用同一份实现。
|
||||
//
|
||||
// 本包刻意只依赖 pion + 标准库,不碰 Wails / 平台代码:① 将来 `gomobile bind` 出 iOS
|
||||
// xcframework 时不会拖入不可编译的桌面依赖;② 一切 HTTP(信令 / 状态机 POST / ICE 凭据)
|
||||
// 留给宿主(桌面 JS / iOS Swift),引擎只经回调收发不透明信令串——纯数据面。
|
||||
//
|
||||
// 线协议与 web 引擎 web/src/features/transfer/p2p.ts 逐字节一致,故 Go 端可与浏览器 /
|
||||
// iOS 的 JS 引擎互通:DataChannel "cdrop-file"(ordered),控制帧 meta/done/ack 走文本帧、
|
||||
// 文件分片走二进制帧;信令 payload 形如 {type, sdp?, candidate?}。
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
channelName = "cdrop-file"
|
||||
chunkSize = 64 * 1024
|
||||
highWatermark = 16 * 1024 * 1024
|
||||
lowWatermark = 4 * 1024 * 1024
|
||||
// 接收端把已收字节回传发送端的节流间隔(毫秒),与 web 引擎 ACK_INTERVAL_MS 一致。
|
||||
ackIntervalMs = 200
|
||||
// 发送端等接收端 ack 追平总量的上限(秒):超时仍照常收尾,避免 ack 丢失致悬挂。
|
||||
ackCompleteTimeoutSec = 30
|
||||
// pion 接收窗口:远高于 WebKit 写死的 256KB,使 pion 作接收端不被 rwnd × RTT 掐死
|
||||
// (吞吐专项的核心修复,见 SetSCTPMaxReceiveBufferSize)。
|
||||
sctpReceiveBuffer = 4 * 1024 * 1024
|
||||
)
|
||||
|
||||
// 控制帧(文本帧),与 p2p.ts 的 meta/done/ack 同形。发送端 meta 仅含 name/size(sha256
|
||||
// 在 streamFile 路径上 web 端亦不发),故此处不带。
|
||||
type metaFrame struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type doneFrame struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ackFrame struct {
|
||||
Type string `json:"type"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// signalPayload 与 p2p.ts 的 SignalPayload 同形:offer/answer 带 sdp(RTCSessionDescriptionInit),
|
||||
// ice 带 candidate(RTCIceCandidateInit)。pion 的 SessionDescription / ICECandidateInit 的 JSON
|
||||
// 标签与浏览器侧一致,故双向可直接 (un)marshal。
|
||||
type signalPayload struct {
|
||||
Type string `json:"type"`
|
||||
SDP *webrtc.SessionDescription `json:"sdp,omitempty"`
|
||||
Candidate *webrtc.ICECandidateInit `json:"candidate,omitempty"`
|
||||
}
|
||||
|
||||
// iceServerJSON 解析 web getICEServers() 的输出:urls 既可能是单串也可能是数组。
|
||||
type iceServerJSON struct {
|
||||
URLs json.RawMessage `json:"urls"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Credential string `json:"credential,omitempty"`
|
||||
}
|
||||
|
||||
// parseICEServers 把宿主传来的 ICE 服务器 JSON(与 web 同源,来自 /api/calls/credentials)
|
||||
// 解析成 pion 的配置。urls 兼容字符串与字符串数组两种形态。
|
||||
func parseICEServers(raw string) []webrtc.ICEServer {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var arr []iceServerJSON
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]webrtc.ICEServer, 0, len(arr))
|
||||
for _, s := range arr {
|
||||
var urls []string
|
||||
var one string
|
||||
if json.Unmarshal(s.URLs, &one) == nil {
|
||||
urls = []string{one}
|
||||
} else {
|
||||
_ = json.Unmarshal(s.URLs, &urls)
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
continue
|
||||
}
|
||||
srv := webrtc.ICEServer{URLs: urls}
|
||||
if s.Username != "" {
|
||||
srv.Username = s.Username
|
||||
}
|
||||
if s.Credential != "" {
|
||||
srv.Credential = s.Credential
|
||||
}
|
||||
out = append(out, srv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nonCollidingPath 返回 dir 下不与现有文件冲突的目标路径:name、name (1)、name (2)……
|
||||
// 接收端落盘时用,避免覆盖既有文件(对齐 SaveDownload 的不覆盖语义)。
|
||||
func nonCollidingPath(dir, name string) string {
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
return p
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
base := strings.TrimSuffix(name, ext)
|
||||
for i := 1; ; i += 1 {
|
||||
c := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext))
|
||||
if _, err := os.Stat(c); os.IsNotExist(err) {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user