Files
admin c79b176b87 传输修复:P2P 接收混合有界内存 sink + 发送进度/完成语义修正 + 前端 CJK 文字累积修复 + 传输速度显示
- P2P 接收端(桌面)改用混合有界内存 sink:wails:// 自定义 scheme 无持久 WebView 存储致
  OPFS 降级、createWritable 回压不畅,把接收链堵死、饿死 dc.onmessage → 接收方 rwnd=0 →
  发送方 bufferedAmount 卡死 16MiB(PWA→Mac 约 15MB 死锁)。小文件(暂存 < 256MB)纯内存、
  close 整文件过 Go 写盘;超上限大文件流式 spill 到 Go 临时文件(每 64MB 一批),内存恒定
  有界。浏览器 / iOS 接收仍走 OPFS
- 新增桌面流式落盘:download.go 的 Begin/Append/Finalize/AbortStreamingDownload(复用
  SaveDownload 的目录解析 / 文件名 sanitize / 不可写回退 / reveal,临时文件同目录便于原子
  rename),app.go 加 4 个绑定 + net/desktop.ts 封装;含单测 download_stream_test.go;
  revealInFileManager 加 CDROP_NO_REVEAL 守卫(headless / 测试不弹 Finder)
- IncomingSink.close() 返回 SinkResult(blob | 已落盘 path),新增 deliverIncoming 统一
  落地;relay 接收一并改用,桌面大文件经中继也有界落盘
- P2P 发送端进度改按“已交付字节”(bytesSent − bufferedAmount)计,而非已塞进本地 16MiB
  buffer 的量——后者在 buffer 秒满后定格、误报“疑似卡住”;并加 250ms 轮询在 backpressure
  等待期持续刷新进度
- P2P 发送端送完后等 bufferedAmount 抽干到 0 再宣告 completed(原先入 buffer 即标完成,
  与接收端仍在下载的状态不一致)
- 前端动态 CJK 文本累积修复:聚珍 shim 把就地更新的 CJK 文本节点 charify 拆段后,旧碎片
  残留累积(典型“发送到 X”切设备多出“到”)。新增 DynText(key=文本内容触发整体重挂、
  保留中西排版),应用于发送按钮 / 设备名 / 在线数 / 相对时间 / 会话与令牌活跃时间 / 消息
  计数 / 剪贴板来源 / 问候语等就地更新处
- 传输卡片新增实时速度:store upsertTransfer 据相邻样本差分 + EMA 平滑算 bytesPerSec,
  TransferRow 在字节流动且未卡死时以“X/秒”展示(点分隔 meta 行追加)
2026-06-23 15:29:10 +08:00

137 lines
4.3 KiB
Go

package platform
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// streamAll runs the full Begin → Append* → Finalize cycle for one session into
// dir and returns the final path. CDROP_NO_REVEAL keeps Finder/Explorer shut.
func streamAll(t *testing.T, dir, sessionID, name string, chunks ...[]byte) string {
t.Helper()
t.Setenv("CDROP_NO_REVEAL", "1")
if err := BeginStreamingDownload(dir, sessionID); err != nil {
t.Fatalf("Begin: %v", err)
}
for i, c := range chunks {
if err := AppendStreamingDownload(sessionID, c); err != nil {
t.Fatalf("Append %d: %v", i, err)
}
}
path, err := FinalizeStreamingDownload(sessionID, name)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
return path
}
func TestStreamingDownload_WritesAllBytes(t *testing.T) {
dir := t.TempDir()
path := streamAll(t, dir, "sess-1", "greeting.txt",
[]byte("hello "), []byte("streamed "), []byte("world"))
if got, want := path, filepath.Join(dir, "greeting.txt"); got != want {
t.Errorf("path = %q, want %q", got, want)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if want := "hello streamed world"; string(got) != want {
t.Errorf("content = %q, want %q", got, want)
}
// The .part temp must be gone after a successful finalize.
if _, err := os.Stat(filepath.Join(dir, ".cdrop-sess-1.part")); !os.IsNotExist(err) {
t.Errorf("temp file not cleaned up: %v", err)
}
}
func TestStreamingDownload_NoCollisionOverwrite(t *testing.T) {
dir := t.TempDir()
// Pre-existing file with the same name must not be overwritten.
if err := os.WriteFile(filepath.Join(dir, "f.bin"), []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
path := streamAll(t, dir, "sess-2", "f.bin", []byte("new"))
if got, want := path, filepath.Join(dir, "f (1).bin"); got != want {
t.Errorf("path = %q, want %q", got, want)
}
orig, _ := os.ReadFile(filepath.Join(dir, "f.bin"))
if string(orig) != "original" {
t.Errorf("original file was overwritten: %q", orig)
}
}
func TestStreamingDownload_SanitizesTraversalName(t *testing.T) {
dir := t.TempDir()
// A crafted name with path components must collapse to a bare filename under
// dir — it must never escape via "../".
path := streamAll(t, dir, "sess-3", "../../etc/evil", []byte("x"))
if filepath.Dir(path) != dir {
t.Errorf("escaped dir: path = %q, dir = %q", path, dir)
}
if got := filepath.Base(path); got != "evil" {
t.Errorf("base = %q, want %q", got, "evil")
}
}
func TestStreamingDownload_AbortRemovesTemp(t *testing.T) {
dir := t.TempDir()
t.Setenv("CDROP_NO_REVEAL", "1")
if err := BeginStreamingDownload(dir, "sess-4"); err != nil {
t.Fatalf("Begin: %v", err)
}
if err := AppendStreamingDownload("sess-4", []byte("partial")); err != nil {
t.Fatalf("Append: %v", err)
}
tmp := filepath.Join(dir, ".cdrop-sess-4.part")
if _, err := os.Stat(tmp); err != nil {
t.Fatalf("temp should exist mid-stream: %v", err)
}
AbortStreamingDownload("sess-4")
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
t.Errorf("temp file not removed after abort: %v", err)
}
// A second append after abort must error, not panic or write a stray file.
if err := AppendStreamingDownload("sess-4", []byte("late")); err == nil {
t.Error("Append after abort: want error, got nil")
}
}
func TestStreamingDownload_ReBeginReplacesStale(t *testing.T) {
dir := t.TempDir()
t.Setenv("CDROP_NO_REVEAL", "1")
// First Begin + partial write, then a second Begin for the same session id
// (a retried transfer) must truncate, not append to, the stale temp.
if err := BeginStreamingDownload(dir, "sess-5"); err != nil {
t.Fatalf("Begin 1: %v", err)
}
if err := AppendStreamingDownload("sess-5", []byte("STALE")); err != nil {
t.Fatalf("Append 1: %v", err)
}
if err := BeginStreamingDownload(dir, "sess-5"); err != nil {
t.Fatalf("Begin 2: %v", err)
}
if err := AppendStreamingDownload("sess-5", []byte("fresh")); err != nil {
t.Fatalf("Append 2: %v", err)
}
path, err := FinalizeStreamingDownload("sess-5", "out.bin")
if err != nil {
t.Fatalf("Finalize: %v", err)
}
got, _ := os.ReadFile(path)
if !bytes.Equal(got, []byte("fresh")) {
t.Errorf("content = %q, want %q (stale bytes leaked)", got, "fresh")
}
}
func TestStreamingDownload_FinalizeUnknownSession(t *testing.T) {
if _, err := FinalizeStreamingDownload("never-began", "x"); err == nil {
t.Error("Finalize unknown session: want error, got nil")
}
}