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

147 lines
4.2 KiB
Go

package engine
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
)
// sigQueue 是测试里每个引擎的入站信令 FIFO:单消费者保序处理,确保 offer/answer 先于
// 其后 trickle 的 ICE 候选被处理(pion 的 AddICECandidate 在 remote description 未设时报错)。
type sigQueue struct {
ch chan [2]string // [fromPeer, payloadJSON]
eng *Engine
}
func newSigQueue() *sigQueue { return &sigQueue{ch: make(chan [2]string, 256)} }
func (q *sigQueue) run(t *testing.T, done <-chan struct{}) {
go func() {
for {
select {
case <-done:
return
case m := <-q.ch:
if err := q.eng.HandleSignal(m[0], m[1]); err != nil {
t.Logf("HandleSignal(%s) err: %v", m[0], err)
}
}
}
}()
}
// peerEvents 把一端的出站信令投到对端队列,并暴露 saved/failed 通道供断言。
type peerEvents struct {
t *testing.T
selfName string
out *sigQueue // 对端队列
saved chan string
failed chan string
}
func (p *peerEvents) OnProgress(string, int64) {}
func (p *peerEvents) OnState(_ string, st string) {
if st == "failed" {
select {
case p.failed <- st:
default:
}
}
}
func (p *peerEvents) OnSignal(_ string, _ string, payloadJSON string) {
// 对端看到的 fromPeer 即本端名字。
p.out.ch <- [2]string{p.selfName, payloadJSON}
}
func (p *peerEvents) OnSaved(_ string, path string) {
select {
case p.saved <- path:
default:
}
}
func (p *peerEvents) OnIcePair(_ string, _ string, _ string) {}
func (p *peerEvents) OnLog(line string) { p.t.Log(line) }
// TestEngineTransfer 端到端:两个进程内 pion peer(A 发 / B 收)经 host 候选直连,传一个
// 3MB 已知内容的文件,断言落盘字节与源一致。覆盖线协议(meta/chunk/done/ack)、水位回压、
// 接收端落盘改名。
func TestEngineTransfer(t *testing.T) {
dir := t.TempDir()
srcPath := filepath.Join(dir, "payload.bin")
dlDir := filepath.Join(dir, "downloads")
if err := os.MkdirAll(dlDir, 0o755); err != nil {
t.Fatal(err)
}
// 3MB 确定性内容:触发多片 + 至少一次回压窗口。
const size = 3 * 1024 * 1024
want := make([]byte, size)
for i := range want {
want[i] = byte(i*7 + 3)
}
if err := os.WriteFile(srcPath, want, 0o644); err != nil {
t.Fatal(err)
}
qA, qB := newSigQueue(), newSigQueue()
evA := &peerEvents{t: t, selfName: "A", out: qB, saved: make(chan string, 1), failed: make(chan string, 1)}
evB := &peerEvents{t: t, selfName: "B", out: qA, saved: make(chan string, 1), failed: make(chan string, 1)}
engA := New(Config{DownloadDir: dlDir}, evA) // 发送端(不落盘)
engB := New(Config{DownloadDir: dlDir}, evB) // 接收端
qA.eng, qB.eng = engA, engB
done := make(chan struct{})
defer close(done)
qA.run(t, done)
qB.run(t, done)
const sid = "sess-1"
if err := engB.StartIncoming(sid, "A", ""); err != nil { // 先武装接收端
t.Fatalf("StartIncoming: %v", err)
}
if err := engA.StartOutgoing(sid, "B", srcPath, ""); err != nil {
t.Fatalf("StartOutgoing: %v", err)
}
var savedPath string
select {
case savedPath = <-evB.saved:
case <-evB.failed:
t.Fatal("receiver reported failed")
case <-evA.failed:
t.Fatal("sender reported failed")
case <-time.After(20 * time.Second):
t.Fatal("timeout waiting for transfer to complete")
}
got, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if len(got) != len(want) {
t.Fatalf("size mismatch: got %d want %d", len(got), len(want))
}
if !bytes.Equal(got, want) {
t.Fatal("content mismatch between source and received file")
}
// 给发送端 waitAck/收尾一点时间收束,避免泄漏 goroutine 噪声(非断言项)。
time.Sleep(200 * time.Millisecond)
}
// TestParseICEServers 验证 urls 单串 / 数组两种形态都能解析(与 web getICEServers 输出兼容)。
func TestParseICEServers(t *testing.T) {
out := parseICEServers(`[{"urls":"stun:a:1"},{"urls":["turns:b:2","turn:b:3"],"username":"u","credential":"c"}]`)
if len(out) != 2 {
t.Fatalf("want 2 servers, got %d", len(out))
}
if len(out[0].URLs) != 1 || out[0].URLs[0] != "stun:a:1" {
t.Errorf("server0 urls wrong: %+v", out[0].URLs)
}
if len(out[1].URLs) != 2 || out[1].Username != "u" {
t.Errorf("server1 wrong: %+v", out[1])
}
}