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

525 lines
14 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 (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/pion/ice/v4"
"github.com/pion/webrtc/v4"
)
const (
roleSender = "sender"
roleReceiver = "receiver"
)
// session 是一条 P2P 传输的全部状态:pion 连接 + 数据通道 + 角色相关的收发循环。
type session struct {
eng *Engine
id string
peer string
role string
pc *webrtc.PeerConnection
dc *webrtc.DataChannel
// 发送端
filePath string
fileSize int64
// 计数器(跨 goroutine:发送循环 / pion 回调)。
bytesSent atomic.Int64 // 已塞进本地 SCTP 缓冲的字节
received atomic.Int64 // 接收端已写盘字节
acked atomic.Int64 // 接收端经 ack 回传的已收字节(发送端进度/完成依据)
// 接收端落盘
out *os.File
outName string
bufLow chan struct{} // OnBufferedAmountLow 唤醒发送循环
done chan struct{} // 会话终结信号
closeOnce sync.Once
stateMu sync.Mutex
state string
}
func newSession(e *Engine, id, peer, role string, pc *webrtc.PeerConnection) *session {
return &session{
eng: e,
id: id,
peer: peer,
role: role,
pc: pc,
bufLow: make(chan struct{}, 1),
done: make(chan struct{}),
}
}
// newPeerConnection 建一个配了大 SCTP 接收窗的 pion 连接。SetSCTPMaxReceiveBufferSize 把
// 接收窗顶到 4MB(远高于 WebKit 写死的 256KB),是吞吐专项的核心——使本端作接收端时
// `吞吐 ≈ rwnd/RTT` 的天花板抬高一个量级。pion 默认用真实 IP 的 host 候选(不做 mDNS
// 混淆),故同内网天然落到 host↔host 直连,避开 mac WKWebView 的 srflx 退化。
func newPeerConnection(iceServersJSON string) (*webrtc.PeerConnection, error) {
se := webrtc.SettingEngine{}
se.SetSCTPMaxReceiveBufferSize(sctpReceiveBuffer)
// 解析对端的 mDNS.local)候选。iOS / Safari 出于隐私只播 mDNS host 候选;pion 默认不
// 解析,便无法与之成 host↔host、退而走 srflx/relay 慢路径(实测 mac→iOS 落到 relay↔srflx、
// 1MB/s 且停滞)。QueryOnly=解析对端 mDNS、本端仍播真实 IP host 候选(对端可直接用),
// 双向打通同内网直连——这是吞吐的前提(host↔host 低 RTT 下 iOS 的 256KB rwnd 才非约束)。
se.SetICEMulticastDNSMode(ice.MulticastDNSModeQueryOnly)
api := webrtc.NewAPI(webrtc.WithSettingEngine(se))
pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: parseICEServers(iceServersJSON)})
if err != nil {
return nil, fmt.Errorf("new peerconnection: %w", err)
}
return pc, nil
}
// wireCommon 绑两端共用的回调:trickle ICE 候选外发、连接状态变化。
func (s *session) wireCommon() {
s.pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil {
s.eng.ev.OnLog(fmt.Sprintf("session %s local gathering done", s.id))
return
}
s.eng.ev.OnLog(fmt.Sprintf("session %s cand local: %s/%s", s.id, c.Typ, c.Protocol))
init := c.ToJSON()
s.emitSignal(s.peer, nil, &init)
})
// ICE 连接态变化日志:诊断「卡在等待对方接受 → 退中继」时连到哪一步(checking/connected/
// failed/disconnected)。
s.pc.OnICEConnectionStateChange(func(st webrtc.ICEConnectionState) {
s.eng.ev.OnLog(fmt.Sprintf("session %s ice: %s", s.id, st))
})
s.pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) {
switch st {
case webrtc.PeerConnectionStateConnected:
s.setState("connected")
s.logSelectedPair()
case webrtc.PeerConnectionStateFailed:
s.fail(errors.New("peerconnection failed"))
case webrtc.PeerConnectionStateClosed:
s.setState("closed")
}
})
}
// --- 发送端 ---
func (s *session) bindSenderDC(dc *webrtc.DataChannel) {
s.dc = dc
dc.SetBufferedAmountLowThreshold(lowWatermark)
dc.OnBufferedAmountLow(func() {
select {
case s.bufLow <- struct{}{}:
default:
}
})
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
if !msg.IsString {
return
}
var a ackFrame
if json.Unmarshal(msg.Data, &a) == nil && a.Type == "ack" {
if a.Bytes > s.acked.Load() {
s.acked.Store(a.Bytes)
s.emitProgress()
}
}
})
dc.OnOpen(func() { go s.streamFile() })
}
// streamFile 把文件按 64KB 分片经数据通道发出,水位回压与 web 引擎一致:缓冲将越 HIGH 前
// 等抽干到 LOW。发完 done 帧后先抽干本地缓冲、再等接收端 ack 追平总量才宣告 completed
// (对齐接收端真实收程,避免发送端抢先完成)。
func (s *session) streamFile() {
f, err := os.Open(s.filePath)
if err != nil {
s.fail(fmt.Errorf("open %s: %w", s.filePath, err))
return
}
defer f.Close()
meta, _ := json.Marshal(metaFrame{Type: "meta", Name: filepath.Base(s.filePath), Size: s.fileSize})
if err := s.dc.SendText(string(meta)); err != nil {
s.fail(fmt.Errorf("send meta: %w", err))
return
}
buf := make([]byte, chunkSize)
for {
select {
case <-s.done:
return
default:
}
n, rerr := f.Read(buf)
if n > 0 {
if s.dc.BufferedAmount()+uint64(n) > highWatermark {
s.waitBufferLow(lowWatermark)
}
if err := s.dc.Send(buf[:n]); err != nil {
s.fail(fmt.Errorf("send chunk: %w", err))
return
}
s.bytesSent.Add(int64(n))
s.emitProgress()
}
if rerr == io.EOF {
break
}
if rerr != nil {
s.fail(fmt.Errorf("read %s: %w", s.filePath, rerr))
return
}
}
doneB, _ := json.Marshal(doneFrame{Type: "done"})
_ = s.dc.SendText(string(doneB))
s.waitDrained()
s.waitAck(s.fileSize)
s.setState("completed")
}
// --- 接收端 ---
func (s *session) bindReceiverDC(dc *webrtc.DataChannel) {
s.dc = dc
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
if msg.IsString {
s.handleControl(msg.Data)
return
}
s.writeChunk(msg.Data)
})
go s.ackLoop()
}
func (s *session) handleControl(data []byte) {
var probe struct {
Type string `json:"type"`
}
if json.Unmarshal(data, &probe) != nil {
return
}
switch probe.Type {
case "meta":
var m metaFrame
if json.Unmarshal(data, &m) == nil {
s.openOutput(m.Name)
}
case "done":
s.finalize()
}
}
func (s *session) openOutput(name string) {
s.outName = name
tmp := filepath.Join(s.eng.downloadDir(), "."+s.id+".part")
f, err := os.Create(tmp)
if err != nil {
s.fail(fmt.Errorf("create %s: %w", tmp, err))
return
}
s.out = f
}
// writeChunk 直接写盘——无 base64 桥、无 OPFS、无 WebView 内存。pion 串行投递同一通道的
// OnMessage,故写入天然有序。
func (s *session) writeChunk(b []byte) {
if s.out != nil {
if _, err := s.out.Write(b); err != nil {
s.fail(fmt.Errorf("write chunk: %w", err))
return
}
}
s.received.Add(int64(len(b)))
s.emitProgress()
}
// ackLoop 每 200ms 把最新已收字节回传发送端(节流,避免每片都回与下行争用通道)。
func (s *session) ackLoop() {
t := time.NewTicker(ackIntervalMs * time.Millisecond)
defer t.Stop()
var lastSent int64 = -1
for {
select {
case <-s.done:
return
case <-t.C:
if r := s.received.Load(); r != lastSent {
s.sendAck(r)
lastSent = r
}
}
}
}
func (s *session) sendAck(bytes int64) {
if s.dc == nil || s.dc.ReadyState() != webrtc.DataChannelStateOpen {
return
}
b, _ := json.Marshal(ackFrame{Type: "ack", Bytes: bytes})
_ = s.dc.SendText(string(b))
}
// finalize 在收到 done 帧时调用:done 意味所有分片已到(ordered),received 已等于总量。
// 立即回最终 ack、关闭文件、改名落到不冲突路径,报告路径与 completed。
func (s *session) finalize() {
s.sendAck(s.received.Load())
if s.out != nil {
s.out.Close()
dir := s.eng.downloadDir()
final := nonCollidingPath(dir, s.outName)
tmp := filepath.Join(dir, "."+s.id+".part")
if err := os.Rename(tmp, final); err != nil {
s.fail(fmt.Errorf("rename to %s: %w", final, err))
return
}
s.out = nil
s.eng.ev.OnSaved(s.id, final)
}
s.setState("completed")
}
// --- 信令 ---
func (s *session) handleSignal(data []byte) error {
var p signalPayload
if err := json.Unmarshal(data, &p); err != nil {
return fmt.Errorf("decode signal: %w", err)
}
s.eng.ev.OnLog(fmt.Sprintf("session %s signal in: %s", s.id, p.Type))
switch p.Type {
case "offer":
if p.SDP == nil {
return errors.New("offer missing sdp")
}
if err := s.pc.SetRemoteDescription(*p.SDP); err != nil {
return fmt.Errorf("set remote (offer): %w", err)
}
ans, err := s.pc.CreateAnswer(nil)
if err != nil {
return fmt.Errorf("create answer: %w", err)
}
if err := s.pc.SetLocalDescription(ans); err != nil {
return fmt.Errorf("set local (answer): %w", err)
}
s.emitSignal(s.peer, &ans, nil)
case "answer":
if p.SDP == nil {
return errors.New("answer missing sdp")
}
if err := s.pc.SetRemoteDescription(*p.SDP); err != nil {
return fmt.Errorf("set remote (answer): %w", err)
}
case "ice":
if p.Candidate == nil {
return nil
}
if err := s.pc.AddICECandidate(*p.Candidate); err != nil {
return fmt.Errorf("add ice candidate: %w", err)
}
}
return nil
}
// emitSignal 把一条出站信令交给宿主 POST。type 由载荷推导:有 candidate 即 ice,否则取
// sdp 自身的类型(offer/answer),与 p2p.ts 的 payload.type 语义一致。
func (s *session) emitSignal(toPeer string, sdp *webrtc.SessionDescription, cand *webrtc.ICECandidateInit) {
p := signalPayload{SDP: sdp, Candidate: cand}
switch {
case cand != nil:
p.Type = "ice"
case sdp != nil:
p.Type = sdp.Type.String()
}
b, err := json.Marshal(p)
if err != nil {
return
}
s.eng.ev.OnSignal(s.id, toPeer, string(b))
}
// --- 进度 / 状态 / 收尾 ---
func (s *session) emitProgress() {
var b int64
if s.role == roleSender {
if a := s.acked.Load(); a > 0 {
b = min(s.fileSize, a)
} else {
b = s.bytesSent.Load() - int64(s.dc.BufferedAmount())
if b < 0 {
b = 0
}
}
} else {
b = s.received.Load()
}
s.eng.ev.OnProgress(s.id, b)
}
// waitBufferLow 等本地 SCTP 缓冲落到 threshold 以下:靠 OnBufferedAmountLow 事件唤醒,
// 兼一个 50ms 轮询兜底(事件阈值设在 lowWatermark,其它 threshold 靠轮询)。
func (s *session) waitBufferLow(threshold uint64) {
start := time.Now()
warned := false
for {
if s.dc.BufferedAmount() <= threshold {
return
}
select {
case <-s.bufLow:
case <-time.After(50 * time.Millisecond):
case <-s.done:
return
}
// 停滞看门狗:本地缓冲长时间不抽干=SCTP 不再向网络交付,多为接收端 rwnd=0
// (主线程/落盘卡住)或中继路径拥塞窗口塌缩。打一条诊断日志(不中断,polling 接力)。
if !warned && time.Since(start) > 5*time.Second {
warned = true
s.eng.ev.OnLog(fmt.Sprintf(
"session %s send stalled >5s: buffered=%d sent=%d acked=%d",
s.id, s.dc.BufferedAmount(), s.bytesSent.Load(), s.acked.Load()))
}
}
}
// logSelectedPair 在连接建立后异步取 pion 选中的候选对类型(host/srflx/prflx/relay)并打日志,
// 用于确认是否走上同内网 host↔host 直连。nominated pair 在 connected 时常未定,轮询几次。
func (s *session) logSelectedPair() {
go func() {
for i := 0; i < 12; i += 1 {
if local, remote, ok := s.selectedPair(); ok {
s.eng.ev.OnLog(fmt.Sprintf("session %s ice pair: local=%s remote=%s", s.id, local, remote))
s.eng.ev.OnIcePair(s.id, local, remote)
return
}
select {
case <-time.After(300 * time.Millisecond):
case <-s.done:
return
}
}
}()
}
func (s *session) selectedPair() (local, remote string, ok bool) {
defer func() { _ = recover() }() // 早期连接链路可能尚 nil,吞 panic
sctp := s.pc.SCTP()
if sctp == nil {
return "", "", false
}
dtls := sctp.Transport()
if dtls == nil {
return "", "", false
}
it := dtls.ICETransport()
if it == nil {
return "", "", false
}
pair, err := it.GetSelectedCandidatePair()
if err != nil || pair == nil || pair.Local == nil || pair.Remote == nil {
return "", "", false
}
local = fmt.Sprintf("%s/%s", pair.Local.Typ, pair.Local.Protocol)
remote = fmt.Sprintf("%s/%s", pair.Remote.Typ, pair.Remote.Protocol)
return local, remote, true
}
// waitDrained 等本地缓冲彻底抽干(done 帧也已离开本端 SCTP)。
func (s *session) waitDrained() {
for {
if s.dc.BufferedAmount() == 0 {
return
}
select {
case <-time.After(20 * time.Millisecond):
case <-s.done:
return
}
}
}
// waitAck 等接收端 ack 追平 total(确认已全收)。超时兜底;无任何 ack(不应发生,因对端
// 皆回 ack)则不空等。
func (s *session) waitAck(total int64) {
if s.acked.Load() == 0 {
return
}
deadline := time.Now().Add(ackCompleteTimeoutSec * time.Second)
for s.acked.Load() < total {
if time.Now().After(deadline) {
return
}
select {
case <-time.After(50 * time.Millisecond):
case <-s.done:
return
}
}
}
func (s *session) setState(st string) {
s.stateMu.Lock()
// 终态不可被覆盖:completed/failed 之后即便连接随后 closed/failed 也不回退——否则接收端
// 完成后、发送端关连接时本端会把已完成会话误翻成 closed/failed。
if s.state == st || s.state == "completed" || s.state == "failed" {
s.stateMu.Unlock()
return
}
s.state = st
s.stateMu.Unlock()
s.eng.ev.OnState(s.id, st)
switch st {
case "failed", "closed":
s.cleanup()
case "completed":
// 不立即拆连接。接收端在 finalize 里刚发出最终 ack;若此刻就关通道,发送端(尤其在
// 前向数据把链路打满、反向 ack 被饿的高吞吐场景)可能收不到最终 ack → 误判失败(实测
// iOS→mac 实际成功却被 iOS 标失败、进度停在 210/271)。改为留连接:ackLoop 每 200ms 续发
// 最终 ack,待前向洪流结束、反向腾出后送达发送端。真正拆连接交给 SSE 终态驱动的 Cancel
// hub→p2pCleanup→P2PCancel);60s 兜底防泄漏。
go func() {
select {
case <-time.After(60 * time.Second):
s.cleanup()
case <-s.done:
}
}()
}
}
func (s *session) fail(err error) {
s.eng.ev.OnLog(fmt.Sprintf("session %s fail: %v", s.id, err))
s.setState("failed")
}
func (s *session) cleanup() {
s.closeOnce.Do(func() {
close(s.done)
if s.out != nil {
s.out.Close()
// 半截 .part 留作诊断证据由上层决定清理;此处仅关句柄。
}
if s.dc != nil {
_ = s.dc.Close()
}
if s.pc != nil {
_ = s.pc.Close()
}
s.eng.remove(s.peer, s)
})
}