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 派生纯文本。
170 lines
5.4 KiB
Go
170 lines
5.4 KiB
Go
package engine
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"sync"
|
||
|
||
"github.com/pion/webrtc/v4"
|
||
)
|
||
|
||
// Events 由宿主实现,接收引擎的回调。所有方法签名仅用 string / int64——刻意保持
|
||
// gobind-legal,使同一接口将来经 gomobile 暴露给 iOS Swift 时无需改形。
|
||
//
|
||
// 回调可能在 pion 的任意 goroutine 上触发;宿主侧的实现需自行保证线程安全
|
||
// (桌面 Wails 的 EventsEmit、iOS 的 DispatchQueue.main 均可跨线程调用)。
|
||
type Events interface {
|
||
// OnProgress 报告已传字节:发送端=接收端 ack 追平的字节(无 ack 时回退本地已交付估计),
|
||
// 接收端=已写盘字节。
|
||
OnProgress(sessionID string, bytes int64)
|
||
// OnState 报告会话状态:connected / completed / failed / closed。宿主据此驱动 store
|
||
// 与状态机 POST(/p2p 于 connected、/done 于接收端 completed、/fail 于 failed)。
|
||
OnState(sessionID string, state string)
|
||
// OnSignal 交出一条出站信令(offer/answer/ice),宿主负责 POST /api/hub/signal{to,payload}。
|
||
OnSignal(sessionID string, toPeer string, payloadJSON string)
|
||
// OnSaved 报告接收端落盘完成的绝对路径,供宿主提示用户。
|
||
OnSaved(sessionID string, path string)
|
||
// OnIcePair 报告连接建立后选中的候选对(host/srflx/prflx/relay,含协议,形如 "host/udp"),
|
||
// 供宿主在调试面板展示「实际通过哪条路径连上」。
|
||
OnIcePair(sessionID string, local string, remote string)
|
||
// OnLog 输出诊断信息。
|
||
OnLog(line string)
|
||
}
|
||
|
||
// Config 由宿主在构造时提供。DownloadDir 是接收文件的落地目录(桌面 ResolveDownloadDir /
|
||
// iOS 应用沙盒)——引擎直接写盘,不再经 base64 桥。
|
||
type Config struct {
|
||
DownloadDir string
|
||
}
|
||
|
||
// Engine 持有所有活跃会话。会话按对端设备名(peer)索引,与 web 引擎 p2pSessions 的键一致:
|
||
// 信令仅携带 from/to 设备名、不带 sessionID,故入站信令只能按对端路由(同一对端同一时刻
|
||
// 至多一条会话——沿用 web 端的同等约束)。
|
||
type Engine struct {
|
||
cfg Config
|
||
ev Events
|
||
mu sync.Mutex
|
||
byPeer map[string]*session
|
||
}
|
||
|
||
// New 构造引擎。ev 不可为 nil。
|
||
func New(cfg Config, ev Events) *Engine {
|
||
return &Engine{cfg: cfg, ev: ev, byPeer: make(map[string]*session)}
|
||
}
|
||
|
||
// SetDownloadDir 更新落地目录(设置页改下载目录时调用)。
|
||
func (e *Engine) SetDownloadDir(dir string) {
|
||
e.mu.Lock()
|
||
e.cfg.DownloadDir = dir
|
||
e.mu.Unlock()
|
||
}
|
||
|
||
func (e *Engine) downloadDir() string {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
return e.cfg.DownloadDir
|
||
}
|
||
|
||
// StartOutgoing 作为发送端发起:建 PeerConnection + DataChannel,发 offer,通道就绪后
|
||
// 把 filePath 的内容按线协议流式发出。filePath 为本机绝对路径(桌面经原生文件选择获得)。
|
||
func (e *Engine) StartOutgoing(sessionID, peerName, filePath, iceServersJSON string) error {
|
||
fi, err := os.Stat(filePath)
|
||
if err != nil {
|
||
return fmt.Errorf("stat %s: %w", filePath, err)
|
||
}
|
||
if fi.IsDir() {
|
||
return fmt.Errorf("%s is a directory", filePath)
|
||
}
|
||
pc, err := newPeerConnection(iceServersJSON)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
s := newSession(e, sessionID, peerName, roleSender, pc)
|
||
s.filePath = filePath
|
||
s.fileSize = fi.Size()
|
||
e.put(peerName, s)
|
||
s.wireCommon()
|
||
|
||
ordered := true
|
||
dc, err := pc.CreateDataChannel(channelName, &webrtc.DataChannelInit{Ordered: &ordered})
|
||
if err != nil {
|
||
s.fail(fmt.Errorf("create datachannel: %w", err))
|
||
return err
|
||
}
|
||
s.bindSenderDC(dc)
|
||
|
||
offer, err := pc.CreateOffer(nil)
|
||
if err != nil {
|
||
s.fail(fmt.Errorf("create offer: %w", err))
|
||
return err
|
||
}
|
||
if err := pc.SetLocalDescription(offer); err != nil {
|
||
s.fail(fmt.Errorf("set local description: %w", err))
|
||
return err
|
||
}
|
||
s.emitSignal(peerName, &offer, nil)
|
||
return nil
|
||
}
|
||
|
||
// StartIncoming 作为接收端预备:建 PeerConnection 等对端 offer。收到 DataChannel 后按
|
||
// 线协议接收并直接落盘。须在对端 offer 到达前调用(宿主在 transfer:incoming 时预备)。
|
||
func (e *Engine) StartIncoming(sessionID, peerName, iceServersJSON string) error {
|
||
pc, err := newPeerConnection(iceServersJSON)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
s := newSession(e, sessionID, peerName, roleReceiver, pc)
|
||
e.put(peerName, s)
|
||
s.wireCommon()
|
||
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||
s.bindReceiverDC(dc)
|
||
})
|
||
return nil
|
||
}
|
||
|
||
// HandleSignal 把宿主从 SSE 收到的入站信令喂给对应会话(按对端设备名路由)。
|
||
func (e *Engine) HandleSignal(fromPeer, payloadJSON string) error {
|
||
e.mu.Lock()
|
||
s := e.byPeer[fromPeer]
|
||
e.mu.Unlock()
|
||
if s == nil {
|
||
return fmt.Errorf("no active session for peer %q", fromPeer)
|
||
}
|
||
return s.handleSignal([]byte(payloadJSON))
|
||
}
|
||
|
||
// Cancel 拆除指定会话(取消 / 中继回退时由宿主调用)。静默拆除——取消是宿主驱动的,
|
||
// 不再回 OnState 以免与宿主自身的终态处理重入。
|
||
func (e *Engine) Cancel(sessionID string) {
|
||
e.mu.Lock()
|
||
var target *session
|
||
for _, s := range e.byPeer {
|
||
if s.id == sessionID {
|
||
target = s
|
||
break
|
||
}
|
||
}
|
||
e.mu.Unlock()
|
||
if target != nil {
|
||
target.cleanup()
|
||
}
|
||
}
|
||
|
||
func (e *Engine) put(peer string, s *session) {
|
||
e.mu.Lock()
|
||
// 同一对端若已有会话,先拆旧的(沿用 web 端按对端覆盖的语义)。
|
||
if old := e.byPeer[peer]; old != nil && old != s {
|
||
go old.cleanup()
|
||
}
|
||
e.byPeer[peer] = s
|
||
e.mu.Unlock()
|
||
}
|
||
|
||
func (e *Engine) remove(peer string, s *session) {
|
||
e.mu.Lock()
|
||
if e.byPeer[peer] == s {
|
||
delete(e.byPeer, peer)
|
||
}
|
||
e.mu.Unlock()
|
||
}
|