// Package engine 是 cdrop 的原生数据面:用 pion/webrtc 在 Go 进程内完成 P2P 文件收发, // 取代 WebView 内 JS 引擎跑 WebRTC 的旧路径。动机见 desktop/NATIVE-TRANSFER.md——逃离 // WebKit 写死的 256KB SCTP 接收窗 + 渲染器节流,并让桌面与 iOS(经 gomobile)共用同一份实现。 // // 本包刻意只依赖 pion + 标准库,不碰 Wails / 平台代码:① 将来 `gomobile bind` 出 iOS // xcframework 时不会拖入不可编译的桌面依赖;② 一切 HTTP(信令 / 状态机 POST / ICE 凭据) // 留给宿主(桌面 JS / iOS Swift),引擎只经回调收发不透明信令串——纯数据面。 // // 线协议与 web 引擎 web/src/features/transfer/p2p.ts 逐字节一致,故 Go 端可与浏览器 / // iOS 的 JS 引擎互通:DataChannel "commilitia-drop-file"(ordered),控制帧 meta/done/ack 走文本帧、 // 文件分片走二进制帧;信令 payload 形如 {type, sdp?, candidate?}。 package engine import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "github.com/pion/webrtc/v4" ) const ( channelName = "commilitia-drop-file" chunkSize = 64 * 1024 highWatermark = 16 * 1024 * 1024 lowWatermark = 4 * 1024 * 1024 // 接收端把已收字节回传发送端的节流间隔(毫秒),与 web 引擎 ACK_INTERVAL_MS 一致。 ackIntervalMs = 200 // 进度回调上报的节流间隔(毫秒):高吞吐下每片一回调会把宿主主线程打满——iOS 上每片 // 都经桥 evaluateJavaScript,数百次/秒会令 UI 无响应(gomobile 集成实测)。限到 ~10Hz, // 终态另由 emitProgressNow 强发最终值。与 web 引擎 store push 的 10Hz 节流同量级。 progressEmitThrottleMs = 100 // 发送端等接收端 ack 追平总量的上限(秒):超时仍照常收尾,避免 ack 丢失致悬挂。 ackCompleteTimeoutSec = 30 // pion 接收窗口:远高于 WebKit 写死的 256KB,使 pion 作接收端不被 rwnd × RTT 掐死 // (吞吐专项的核心修复,见 SetSCTPMaxReceiveBufferSize)。 sctpReceiveBuffer = 4 * 1024 * 1024 ) // 控制帧(文本帧),与 p2p.ts 的 meta/done/ack 同形。发送端 meta 仅含 name/size(sha256 // 在 streamFile 路径上 web 端亦不发),故此处不带。 type metaFrame struct { Type string `json:"type"` Name string `json:"name"` Size int64 `json:"size"` } type doneFrame struct { Type string `json:"type"` } type ackFrame struct { Type string `json:"type"` Bytes int64 `json:"bytes"` } // signalPayload 与 p2p.ts 的 SignalPayload 同形:offer/answer 带 sdp(RTCSessionDescriptionInit), // ice 带 candidate(RTCIceCandidateInit)。pion 的 SessionDescription / ICECandidateInit 的 JSON // 标签与浏览器侧一致,故双向可直接 (un)marshal。 type signalPayload struct { Type string `json:"type"` SDP *webrtc.SessionDescription `json:"sdp,omitempty"` Candidate *webrtc.ICECandidateInit `json:"candidate,omitempty"` } // iceServerJSON 解析 web getICEServers() 的输出:urls 既可能是单串也可能是数组。 type iceServerJSON struct { URLs json.RawMessage `json:"urls"` Username string `json:"username,omitempty"` Credential string `json:"credential,omitempty"` } // parseICEServers 把宿主传来的 ICE 服务器 JSON(与 web 同源,来自 /api/calls/credentials) // 解析成 pion 的配置。urls 兼容字符串与字符串数组两种形态。 func parseICEServers(raw string) []webrtc.ICEServer { if strings.TrimSpace(raw) == "" { return nil } var arr []iceServerJSON if err := json.Unmarshal([]byte(raw), &arr); err != nil { return nil } out := make([]webrtc.ICEServer, 0, len(arr)) for _, s := range arr { var urls []string var one string if json.Unmarshal(s.URLs, &one) == nil { urls = []string{one} } else { _ = json.Unmarshal(s.URLs, &urls) } if len(urls) == 0 { continue } srv := webrtc.ICEServer{URLs: urls} if s.Username != "" { srv.Username = s.Username } if s.Credential != "" { srv.Credential = s.Credential } out = append(out, srv) } return out } // nonCollidingPath 返回 dir 下不与现有文件冲突的目标路径:name、name (1)、name (2)…… // 接收端落盘时用,避免覆盖既有文件(对齐 SaveDownload 的不覆盖语义)。 func nonCollidingPath(dir, name string) string { p := filepath.Join(dir, name) if _, err := os.Stat(p); os.IsNotExist(err) { return p } ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) for i := 1; ; i += 1 { c := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext)) if _, err := os.Stat(c); os.IsNotExist(err) { return c } } }