Files
Commilitia-Drop/desktop/engine/wire.go
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

121 lines
4.3 KiB
Go
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.
// 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/sizesha256
// 在 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 带 sdpRTCSessionDescriptionInit),
// ice 带 candidateRTCIceCandidateInit)。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
}
}
}