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

170 lines
5.4 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
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()
}