传输修复: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 行追加)
This commit is contained in:
@@ -332,6 +332,29 @@ func (a *App) SaveDownload(name string, data []byte) (string, error) {
|
||||
return platform.SaveDownload(platform.ResolveDownloadDir(), name, data)
|
||||
}
|
||||
|
||||
// BeginDownload / AppendDownload / FinalizeDownload / AbortDownload are the
|
||||
// streaming counterpart of SaveDownload, bound to the WebView. The receiver's
|
||||
// hybrid sink keeps a file in memory until it crosses a cap, then spills the
|
||||
// overflow here so large files don't sit in the WebView heap (the wails:// no-OPFS
|
||||
// deadlock). Begin opens a temp file in the download dir, Append streams batches
|
||||
// (base64 over the bridge), Finalize renames it into place and returns the path,
|
||||
// Abort discards it on cancel / failure.
|
||||
func (a *App) BeginDownload(sessionId string) error {
|
||||
return platform.BeginStreamingDownload(platform.ResolveDownloadDir(), sessionId)
|
||||
}
|
||||
|
||||
func (a *App) AppendDownload(sessionId string, data []byte) error {
|
||||
return platform.AppendStreamingDownload(sessionId, data)
|
||||
}
|
||||
|
||||
func (a *App) FinalizeDownload(sessionId, name string) (string, error) {
|
||||
return platform.FinalizeStreamingDownload(sessionId, name)
|
||||
}
|
||||
|
||||
func (a *App) AbortDownload(sessionId string) {
|
||||
platform.AbortStreamingDownload(sessionId)
|
||||
}
|
||||
|
||||
// ChooseDownloadDir opens a native directory picker (title localised by the
|
||||
// caller) and returns the chosen path, or "" when the user cancels. The page
|
||||
// persists the result via SaveSettings.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SaveDownload writes a received file's bytes into dir (empty → system Downloads),
|
||||
@@ -102,7 +103,12 @@ func uniquePath(dir, name string) string {
|
||||
|
||||
// revealInFileManager opens the OS file manager with the saved file selected.
|
||||
// Best-effort: the save already succeeded, so any error here is ignored.
|
||||
// CDROP_NO_REVEAL skips the side effect — for headless / CI / test runs that
|
||||
// shouldn't pop a Finder/Explorer window.
|
||||
func revealInFileManager(path string) {
|
||||
if os.Getenv("CDROP_NO_REVEAL") != "" {
|
||||
return
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
_ = exec.Command("open", "-R", path).Start()
|
||||
@@ -110,3 +116,159 @@ func revealInFileManager(path string) {
|
||||
_ = exec.Command("explorer", "/select,"+path).Start()
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming downloads — the receiver's hybrid sink (web/.../incomingSink.ts) keeps
|
||||
// a file in memory until it crosses a cap (default 256 MB), then spills to disk
|
||||
// through this manager instead of carrying the whole thing in the WebView heap. We
|
||||
// can't use SaveDownload for that: it takes the complete bytes at once. The reason
|
||||
// the whole hybrid exists is the wails:// scheme has no persistent WebView storage,
|
||||
// so OPFS degrades and starves the receive loop (the PWA→Mac ~15 MB deadlock); the
|
||||
// fix is to stage bounded memory and stream the overflow into a Go-owned temp file.
|
||||
//
|
||||
// The temp file lives in the SAME directory as the final target so finalize is an
|
||||
// atomic rename, not a cross-device copy. The writable dir is chosen up front (with
|
||||
// the same unwritable→system-Downloads fallback as SaveDownload) because once GBs
|
||||
// are streamed we can't cheaply switch volumes.
|
||||
|
||||
type streamingDownload struct {
|
||||
file *os.File
|
||||
dir string // the writable dir chosen at Begin; the final file is renamed into it
|
||||
tmp string // the .part temp path under dir
|
||||
}
|
||||
|
||||
var (
|
||||
streamMu sync.Mutex
|
||||
streamDownloads = map[string]*streamingDownload{}
|
||||
)
|
||||
|
||||
// BeginStreamingDownload opens a .part temp file under a writable download dir for
|
||||
// the session. dir == "" → system Downloads; an unwritable dir falls back to system
|
||||
// Downloads (mirrors SaveDownload). A stale entry for the same session id is closed
|
||||
// and removed first, so a retried transfer never leaks a handle or temp file.
|
||||
func BeginStreamingDownload(dir, sessionId string) error {
|
||||
if dir == "" {
|
||||
dir = DefaultDownloadDir()
|
||||
}
|
||||
// Drop any stale handle for this session FIRST (a retried transfer). Its temp
|
||||
// path equals the new one (both derive from the session id), so removing it
|
||||
// after opening the fresh file would delete the file we just created. Clean
|
||||
// up first; openStreamTemp then re-creates the temp via O_TRUNC.
|
||||
streamMu.Lock()
|
||||
if prev, ok := streamDownloads[sessionId]; ok {
|
||||
_ = prev.file.Close()
|
||||
_ = os.Remove(prev.tmp)
|
||||
delete(streamDownloads, sessionId)
|
||||
}
|
||||
streamMu.Unlock()
|
||||
|
||||
f, dirUsed, tmp, err := openStreamTemp(dir, sessionId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
streamMu.Lock()
|
||||
streamDownloads[sessionId] = &streamingDownload{file: f, dir: dirUsed, tmp: tmp}
|
||||
streamMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// openStreamTemp creates a writable .part temp file under dir, retrying into the
|
||||
// system Downloads dir if dir is unwritable (mirrors SaveDownload's fallback).
|
||||
func openStreamTemp(dir, sessionId string) (*os.File, string, string, error) {
|
||||
f, tmp, err := tryStreamTemp(dir, sessionId)
|
||||
if err == nil {
|
||||
return f, dir, tmp, nil
|
||||
}
|
||||
if def := DefaultDownloadDir(); def != dir {
|
||||
if f2, tmp2, ferr := tryStreamTemp(def, sessionId); ferr == nil {
|
||||
return f2, def, tmp2, nil
|
||||
}
|
||||
}
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
func tryStreamTemp(dir, sessionId string) (*os.File, string, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
tmp := filepath.Join(dir, ".cdrop-"+sanitizeSessionID(sessionId)+".part")
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return f, tmp, nil
|
||||
}
|
||||
|
||||
// sanitizeSessionID reduces the peer-supplied session id to filename-safe chars so
|
||||
// it can name the .part temp file. It only has to be unique and path-safe, not
|
||||
// reversible — the real filename is applied at finalize.
|
||||
func sanitizeSessionID(id string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
if s == "" {
|
||||
s = "session"
|
||||
}
|
||||
if len(s) > 64 {
|
||||
s = s[:64]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// AppendStreamingDownload writes a batch of bytes to the session's temp file.
|
||||
func AppendStreamingDownload(sessionId string, data []byte) error {
|
||||
streamMu.Lock()
|
||||
d := streamDownloads[sessionId]
|
||||
streamMu.Unlock()
|
||||
if d == nil {
|
||||
return fmt.Errorf("no streaming download for session %q", sessionId)
|
||||
}
|
||||
_, err := d.file.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// FinalizeStreamingDownload closes the temp file and renames it to a non-colliding
|
||||
// final name under the same dir (name sanitized like SaveDownload), then reveals
|
||||
// it. Returns the absolute path written.
|
||||
func FinalizeStreamingDownload(sessionId, name string) (string, error) {
|
||||
streamMu.Lock()
|
||||
d := streamDownloads[sessionId]
|
||||
delete(streamDownloads, sessionId)
|
||||
streamMu.Unlock()
|
||||
if d == nil {
|
||||
return "", fmt.Errorf("no streaming download for session %q", sessionId)
|
||||
}
|
||||
if err := d.file.Close(); err != nil {
|
||||
_ = os.Remove(d.tmp)
|
||||
return "", err
|
||||
}
|
||||
safe := sanitizeFileName(name)
|
||||
if safe == "" {
|
||||
safe = "cdrop-download"
|
||||
}
|
||||
target := uniquePath(d.dir, safe)
|
||||
if err := os.Rename(d.tmp, target); err != nil {
|
||||
_ = os.Remove(d.tmp)
|
||||
return "", err
|
||||
}
|
||||
revealInFileManager(target)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// AbortStreamingDownload closes and deletes the session's temp file (cancel /
|
||||
// failure path). No-op when the session isn't streaming.
|
||||
func AbortStreamingDownload(sessionId string) {
|
||||
streamMu.Lock()
|
||||
d := streamDownloads[sessionId]
|
||||
delete(streamDownloads, sessionId)
|
||||
streamMu.Unlock()
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
_ = d.file.Close()
|
||||
_ = os.Remove(d.tmp)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user