ab57afdec2
把桌面 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 派生纯文本。
147 lines
4.2 KiB
Go
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])
|
|
}
|
|
}
|