传输修复: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)
|
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
|
// ChooseDownloadDir opens a native directory picker (title localised by the
|
||||||
// caller) and returns the chosen path, or "" when the user cancels. The page
|
// caller) and returns the chosen path, or "" when the user cancels. The page
|
||||||
// persists the result via SaveSettings.
|
// persists the result via SaveSettings.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SaveDownload writes a received file's bytes into dir (empty → system Downloads),
|
// 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.
|
// revealInFileManager opens the OS file manager with the saved file selected.
|
||||||
// Best-effort: the save already succeeded, so any error here is ignored.
|
// 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) {
|
func revealInFileManager(path string) {
|
||||||
|
if os.Getenv("CDROP_NO_REVEAL") != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
_ = exec.Command("open", "-R", path).Start()
|
_ = exec.Command("open", "-R", path).Start()
|
||||||
@@ -110,3 +116,159 @@ func revealInFileManager(path string) {
|
|||||||
_ = exec.Command("explorer", "/select,"+path).Start()
|
_ = 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button, IconButton, Panel, Tooltip } from "../../ui/primitives";
|
import { Button, DynText, IconButton, Panel, Tooltip } from "../../ui/primitives";
|
||||||
import { toast } from "../../ui/feedback";
|
import { toast } from "../../ui/feedback";
|
||||||
import type { ClipboardState } from "../../store";
|
import type { ClipboardState } from "../../store";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
@@ -159,7 +159,7 @@ export function ClipboardPanel(props: ClipboardPanelProps)
|
|||||||
<div className={s.cloud}>
|
<div className={s.cloud}>
|
||||||
<div className={s.metaRow}>
|
<div className={s.metaRow}>
|
||||||
<span className={s.metaSize}>{sizeText}</span>
|
<span className={s.metaSize}>{sizeText}</span>
|
||||||
<span className={s.metaFrom}>{fromText}</span>
|
<span className={s.metaFrom}><DynText>{fromText}</DynText></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{revealed ? (
|
{revealed ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState, type DragEvent } from "react";
|
|||||||
import { Check, ScanLine } from "lucide-react";
|
import { Check, ScanLine } from "lucide-react";
|
||||||
import { DeviceTypeIcon, StateDot } from "../../ui/glyphs";
|
import { DeviceTypeIcon, StateDot } from "../../ui/glyphs";
|
||||||
import type { DeviceKind } from "../../ui/glyphs";
|
import type { DeviceKind } from "../../ui/glyphs";
|
||||||
|
import { DynText } from "../../ui/primitives";
|
||||||
import type { DeviceInfo } from "../../store";
|
import type { DeviceInfo } from "../../store";
|
||||||
import { formatRelative } from "../../utils/format";
|
import { formatRelative } from "../../utils/format";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
@@ -41,7 +42,7 @@ export function DeviceStrip(props: DeviceStripProps)
|
|||||||
<div>
|
<div>
|
||||||
<span className={s.headerTitle}>{t("home.deviceList.title")}</span>
|
<span className={s.headerTitle}>{t("home.deviceList.title")}</span>
|
||||||
<span className={s.headerCount}>
|
<span className={s.headerCount}>
|
||||||
{t("home.deviceList.onlineSuffix", { count: onlineCount })}
|
<DynText>{t("home.deviceList.onlineSuffix", { count: onlineCount })}</DynText>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={s.headerActions}>
|
<div className={s.headerActions}>
|
||||||
@@ -51,9 +52,11 @@ export function DeviceStrip(props: DeviceStripProps)
|
|||||||
className={s.headerSwitch}
|
className={s.headerSwitch}
|
||||||
onClick={() => setShowOffline((v) => !v)}
|
onClick={() => setShowOffline((v) => !v)}
|
||||||
>
|
>
|
||||||
{showOffline
|
<DynText>
|
||||||
? t("home.deviceList.hideOffline")
|
{showOffline
|
||||||
: t("home.deviceList.showOffline", { count: offlineCount })}
|
? t("home.deviceList.hideOffline")
|
||||||
|
: t("home.deviceList.showOffline", { count: offlineCount })}
|
||||||
|
</DynText>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onLinkDevice && (
|
{onLinkDevice && (
|
||||||
@@ -148,9 +151,11 @@ function DevicePort(props: DevicePortProps)
|
|||||||
<div className={s.meta}>
|
<div className={s.meta}>
|
||||||
<StateDot tone={device.online ? "online" : "offline"} />
|
<StateDot tone={device.online ? "online" : "offline"} />
|
||||||
<span>
|
<span>
|
||||||
{device.online
|
<DynText>
|
||||||
? t("settings.online")
|
{device.online
|
||||||
: t("settings.lastSeen", { time: formatRelative(device.lastSeen) })}
|
? t("settings.online")
|
||||||
|
: t("settings.lastSeen", { time: formatRelative(device.lastSeen) })}
|
||||||
|
</DynText>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Button } from "../../ui/primitives";
|
import { Button, DynText } from "../../ui/primitives";
|
||||||
import type { MessageRecord } from "../../store";
|
import type { MessageRecord } from "../../store";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { formatRelative } from "../../utils/format";
|
import { formatRelative } from "../../utils/format";
|
||||||
@@ -23,7 +23,7 @@ export function MessageList(props: MessageListProps)
|
|||||||
<div>
|
<div>
|
||||||
<span className={s.title}>{t("home.message.title")}</span>
|
<span className={s.title}>{t("home.message.title")}</span>
|
||||||
<span className={s.count}>
|
<span className={s.count}>
|
||||||
{t("home.message.countSuffix", { count: messages.length })}
|
<DynText>{t("home.message.countSuffix", { count: messages.length })}</DynText>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={onClearAll}>
|
<Button variant="ghost" size="sm" onClick={onClearAll}>
|
||||||
@@ -49,7 +49,7 @@ function MessageRow(props: { m: MessageRecord; onDismiss: () => void })
|
|||||||
return (
|
return (
|
||||||
<div className={cls}>
|
<div className={cls}>
|
||||||
<div className={s.body}>
|
<div className={s.body}>
|
||||||
<div className={s.meta}>{arrow} {m.peerName} · {formatRelative(m.sentAt)}</div>
|
<div className={s.meta}>{arrow} {m.peerName} · <DynText>{formatRelative(m.sentAt)}</DynText></div>
|
||||||
<div className={`${s.text} justify`} data-jz-level="paragraph">{m.text}</div>
|
<div className={`${s.text} justify`} data-jz-level="paragraph">{m.text}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Copy, KeyRound, Plus } from "lucide-react";
|
|||||||
import { apiJSON } from "../../net/api";
|
import { apiJSON } from "../../net/api";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { formatRelative } from "../../utils/format";
|
import { formatRelative } from "../../utils/format";
|
||||||
import { Badge, Button, Callout, Panel, TextField } from "../../ui/primitives";
|
import { Badge, Button, Callout, DynText, Panel, TextField } from "../../ui/primitives";
|
||||||
import { toast } from "../../ui/feedback";
|
import { toast } from "../../ui/feedback";
|
||||||
|
|
||||||
type ShortcutTokenView = {
|
type ShortcutTokenView = {
|
||||||
@@ -233,7 +233,7 @@ function TokenRow(props: { token: ShortcutTokenView; revoking: boolean; onRevoke
|
|||||||
date: new Date(token.expires_at * 1000).toLocaleDateString(),
|
date: new Date(token.expires_at * 1000).toLocaleDateString(),
|
||||||
})}
|
})}
|
||||||
{" · "}
|
{" · "}
|
||||||
{lastUsed}
|
<DynText>{lastUsed}</DynText>
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
{!token.revoked && (
|
{!token.revoked && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState, type DragEvent } from "react";
|
import { useRef, useState, type DragEvent } from "react";
|
||||||
import { FileText, MessageSquare, Send, Upload } from "lucide-react";
|
import { FileText, MessageSquare, Send, Upload } from "lucide-react";
|
||||||
import { Button, Panel, Tabs, TextArea } from "../../ui/primitives";
|
import { Button, DynText, Panel, Tabs, TextArea } from "../../ui/primitives";
|
||||||
import { toast } from "../../ui/feedback";
|
import { toast } from "../../ui/feedback";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { formatBytes } from "../../utils/format";
|
import { formatBytes } from "../../utils/format";
|
||||||
@@ -33,13 +33,21 @@ export function Composer(props: ComposerProps)
|
|||||||
|
|
||||||
// 目标缺失时用 muted 提示而非 error 红 —— 提示职责与发送按钮的 disabled
|
// 目标缺失时用 muted 提示而非 error 红 —— 提示职责与发送按钮的 disabled
|
||||||
// 状态重合时不应双倍渲染告警。允许预输入文件/消息,等用户选完设备再点发送。
|
// 状态重合时不应双倍渲染告警。允许预输入文件/消息,等用户选完设备再点发送。
|
||||||
|
// 设备名 span 的内容会随切换就地更新,名字含 CJK 时同样会被 shim 拆段累积;key=名字
|
||||||
|
// 让它整体重挂。「发送到 」是常量文本节点、React 从不改写,无需处理。
|
||||||
const targetLabel = targetDevice
|
const targetLabel = targetDevice
|
||||||
? <>发送到 <span className={s.targetName}>{targetDevice}</span></>
|
? <>发送到 <span className={s.targetName} key={targetDevice}>{targetDevice}</span></>
|
||||||
: <span className={s.targetMuted}>{t("home.cta.selectDevice")}</span>;
|
: <span className={s.targetMuted}>{t("home.cta.selectDevice")}</span>;
|
||||||
|
|
||||||
const canSendFile = !!targetDevice && !!selectedFile;
|
const canSendFile = !!targetDevice && !!selectedFile;
|
||||||
const canSendMsg = !!targetDevice && msgText.trim().length > 0;
|
const canSendMsg = !!targetDevice && msgText.trim().length > 0;
|
||||||
|
|
||||||
|
// 「发送到 X」整串是单个文本节点,切换目标设备时 React 原地改写,聚珍 shim 拆出的旧
|
||||||
|
// 碎片会残留累积(多出「到」)。DynText 用 key=文本让其整体重挂、保留排版又消累积。
|
||||||
|
const sendLabel = targetDevice
|
||||||
|
? <DynText>{t("home.cta.sendTo", { name: targetDevice })}</DynText>
|
||||||
|
: "发送";
|
||||||
|
|
||||||
const handleSubmitFile = async () =>
|
const handleSubmitFile = async () =>
|
||||||
{
|
{
|
||||||
if (!canSendFile) { return; }
|
if (!canSendFile) { return; }
|
||||||
@@ -119,9 +127,7 @@ export function Composer(props: ComposerProps)
|
|||||||
disabled={!canSendFile}
|
disabled={!canSendFile}
|
||||||
onClick={handleSubmitFile}
|
onClick={handleSubmitFile}
|
||||||
>
|
>
|
||||||
{targetDevice
|
{sendLabel}
|
||||||
? t("home.cta.sendTo", { name: targetDevice })
|
|
||||||
: "发送"}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -132,9 +138,7 @@ export function Composer(props: ComposerProps)
|
|||||||
disabled={!canSendMsg}
|
disabled={!canSendMsg}
|
||||||
onClick={handleSubmitMsg}
|
onClick={handleSubmitMsg}
|
||||||
>
|
>
|
||||||
{targetDevice
|
{sendLabel}
|
||||||
? t("home.cta.sendTo", { name: targetDevice })
|
|
||||||
: "发送"}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TransferPhase, TransferRecord } from "../../store";
|
|||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { t, type TranslationKey } from "../../i18n";
|
import { t, type TranslationKey } from "../../i18n";
|
||||||
import { formatBytes } from "../../utils/format";
|
import { formatBytes } from "../../utils/format";
|
||||||
|
import { DynText } from "../../ui/primitives";
|
||||||
import { cancelTransfer, skipWaitRelay } from "./transfer";
|
import { cancelTransfer, skipWaitRelay } from "./transfer";
|
||||||
import { TransferDetails } from "./TransferDetails";
|
import { TransferDetails } from "./TransferDetails";
|
||||||
import s from "./TransferRow.module.css";
|
import s from "./TransferRow.module.css";
|
||||||
@@ -45,6 +46,8 @@ export function TransferRow(props: TransferRowProps)
|
|||||||
&& FLOW_PHASES.has(tr.phase)
|
&& FLOW_PHASES.has(tr.phase)
|
||||||
&& sinceMs >= STALLED_THRESHOLD_MS;
|
&& sinceMs >= STALLED_THRESHOLD_MS;
|
||||||
|
|
||||||
|
const status = userFriendlyStatus(tr, stalled, sinceMs);
|
||||||
|
const speed = speedText(tr, stalled);
|
||||||
const pct = computeProgress(tr);
|
const pct = computeProgress(tr);
|
||||||
const failed = tr.state === "FAILED";
|
const failed = tr.state === "FAILED";
|
||||||
const done = tr.state === "DONE";
|
const done = tr.state === "DONE";
|
||||||
@@ -92,13 +95,22 @@ export function TransferRow(props: TransferRowProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.meta}>
|
<div className={s.meta}>
|
||||||
<span className={s.metaItem}>{userFriendlyStatus(tr, stalled, sinceMs)}</span>
|
{/* 状态文案含 CJK 且每秒 / 随 phase 就地更新;DynText 用 key 让其整体重挂,
|
||||||
|
避免聚珍 shim 拆出的碎片累积(见 DynText)。 */}
|
||||||
|
<span className={s.metaItem}><DynText>{status}</DynText></span>
|
||||||
{bytesText(tr) && (
|
{bytesText(tr) && (
|
||||||
<>
|
<>
|
||||||
<span className={s.dot}>·</span>
|
<span className={s.dot}>·</span>
|
||||||
<span className={s.metaItem}>{bytesText(tr)}</span>
|
<span className={s.metaItem}>{bytesText(tr)}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{speed && (
|
||||||
|
<>
|
||||||
|
<span className={s.dot}>·</span>
|
||||||
|
{/* 速度含 CJK「/秒」且高频更新,同样过 DynText */}
|
||||||
|
<span className={s.metaItem}><DynText>{speed}</DynText></span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.footer}>
|
<div className={s.footer}>
|
||||||
@@ -176,6 +188,17 @@ function bytesText(tr: TransferRecord): string
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 瞬时速度文案。仅在字节流动阶段、未卡死、且有正有限速率时给出——卡死时既有「似乎
|
||||||
|
// 卡住了」状态已表达停滞,再显示一个陈旧的非零速度会误导。速率由 store 做 EMA 平滑。
|
||||||
|
function speedText(tr: TransferRecord, stalled: boolean): string
|
||||||
|
{
|
||||||
|
if (stalled) { return ""; }
|
||||||
|
if (!FLOW_PHASES.has(tr.phase ?? "initializing")) { return ""; }
|
||||||
|
const rate = tr.bytesPerSec;
|
||||||
|
if (!rate || !Number.isFinite(rate) || rate <= 0) { return ""; }
|
||||||
|
return t("transfer.bytesRate", { rate: formatBytes(rate) });
|
||||||
|
}
|
||||||
|
|
||||||
function computeProgress(tr: TransferRecord): number
|
function computeProgress(tr: TransferRecord): number
|
||||||
{
|
{
|
||||||
if (tr.state === "DONE") { return 100; }
|
if (tr.state === "DONE") { return 100; }
|
||||||
|
|||||||
@@ -1,33 +1,65 @@
|
|||||||
// 接收端的字节落地槽,用来避免把整个文件累积到 JS 堆。
|
// 接收端的字节落地槽,用来避免把整个文件累积到 JS 堆。
|
||||||
//
|
//
|
||||||
// 背景:iOS Safari 单 tab 内存预算紧;超过后系统会节流 / 暂停 JS 执行,
|
// 三条路径,按运行环境选:
|
||||||
// 导致 SCTP 不再 ack → sender bufferedAmount 撑满 → 死锁(用户实测 24.4 MB
|
// - 浏览器(含 iOS PWA):流式写 Origin Private File System(OPFS),内存只占
|
||||||
// 卡住的根因)。改成流式写入 Origin Private File System(OPFS)后内存只占
|
// 一个 chunk。iOS Safari 单 tab 内存预算紧,超过即被系统节流 / 暂停 JS → SCTP
|
||||||
// 一个 chunk,iOS 16+ / Chrome / Firefox 都已支持 createWritable。
|
// 不再 ack → sender bufferedAmount 撑满死锁(实测 24.4 MB 卡住的根因),故必须
|
||||||
|
// 流式。Safari 16+ / Chrome 102+ / Firefox 111+ 都已支持 createWritable。
|
||||||
|
// - 桌面(Wails WKWebView):混合有界内存槽。wails:// 自定义 scheme 无持久 WebView
|
||||||
|
// 存储,OPFS 在此降级、createWritable().write() 慢且回压不畅,会把接收 promise
|
||||||
|
// 链堵住、拖垮主线程到 dc.onmessage 饿死 → 接收方 SCTP rwnd=0 → 发送方
|
||||||
|
// bufferedAmount 卡死在 16 MiB → 传输死锁(实测 PWA→Mac 约 15 MB 卡住)。故桌面
|
||||||
|
// 不碰 OPFS:小文件(暂存 < CAP)纯内存累积、close 时整文件过 Go 写盘;越过内存
|
||||||
|
// 上限(CAP=256 MB)的大文件改为流式 spill 到 Go 临时文件(每 64 MB 一批),内存
|
||||||
|
// 恒定有界。桌面 RAM 充裕,整体不会撑爆堆,又彻底避开 OPFS 那条饿死链路。
|
||||||
|
// - 老浏览器 / OPFS 不可用:退回纯内存累积。
|
||||||
//
|
//
|
||||||
// 老浏览器 / OPFS 不可用时退回到老的内存累积方案;移动端可能仍然受影响,
|
// close() 返回 SinkResult:浏览器 / 桌面小文件返回 Blob(走 downloadBlob——浏览器
|
||||||
// 但桌面端无碍。
|
// 原生下载 / 桌面整文件过 Go 写盘);桌面已 spill 的大文件返回已落盘绝对路径(文件
|
||||||
|
// 已在 Go 侧写好,跳过 Blob)。
|
||||||
|
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { isDesktop, saveIncomingFileDesktop } from "../../net/desktop";
|
import {
|
||||||
|
abortIncomingDownloadDesktop,
|
||||||
|
appendIncomingDownloadDesktop,
|
||||||
|
beginIncomingDownloadDesktop,
|
||||||
|
finalizeIncomingDownloadDesktop,
|
||||||
|
isDesktop,
|
||||||
|
saveIncomingFileDesktop,
|
||||||
|
} from "../../net/desktop";
|
||||||
import { toast } from "../../ui/feedback";
|
import { toast } from "../../ui/feedback";
|
||||||
|
|
||||||
|
// close() 两态结果:blob=整文件在内存,交给 downloadBlob;saved=桌面大文件已流式
|
||||||
|
// 落盘,path 是最终绝对路径(无需再过 Blob)。
|
||||||
|
export type SinkResult =
|
||||||
|
| { kind: "blob"; blob: Blob }
|
||||||
|
| { kind: "saved"; path: string };
|
||||||
|
|
||||||
export interface IncomingSink
|
export interface IncomingSink
|
||||||
{
|
{
|
||||||
/** 顺序写一个 chunk;返回的 Promise 只有完全落盘后才 resolve,自然 backpressure */
|
/** 顺序写一个 chunk;返回的 Promise 只有完全落盘后才 resolve,自然 backpressure */
|
||||||
write(chunk: ArrayBuffer): Promise<void>;
|
write(chunk: ArrayBuffer): Promise<void>;
|
||||||
/** 收到 done 帧后调用:finalize + 取出最终 Blob 给浏览器下载 */
|
/** 收到 done 帧后调用:finalize 并返回最终结果(Blob 或已落盘路径) */
|
||||||
close(): Promise<Blob>;
|
close(): Promise<SinkResult>;
|
||||||
/** 失败 / 取消路径上调用,确保 OPFS 临时文件被删 */
|
/** 失败 / 取消路径上调用,确保 OPFS / 流式临时文件被删 */
|
||||||
cancel(): Promise<void>;
|
cancel(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 桌面混合 sink 的内存上限与 spill 批大小:暂存达 CAP 即转流式落盘,之后每满一批刷。
|
||||||
|
const DESKTOP_MEMORY_CAP = 256 * 1024 * 1024;
|
||||||
|
const DESKTOP_FLUSH_BATCH = 64 * 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 优先 OPFS,失败回退内存。sessionId 用作 OPFS 临时文件名(确保两台设备
|
* 按环境选 sink:桌面走混合有界内存槽(见文件头注释),浏览器优先 OPFS、失败回退
|
||||||
* 同时收不同会话不会撞文件)。
|
* 内存。sessionId 用作 OPFS 临时文件名 / 桌面流式落盘 key(确保并发会话不撞文件);
|
||||||
|
* fileName 供桌面 spill 落盘后命名(仅大文件路径用到)。
|
||||||
*/
|
*/
|
||||||
export async function openIncomingSink(sessionId: string): Promise<IncomingSink>
|
export async function openIncomingSink(sessionId: string, fileName: string): Promise<IncomingSink>
|
||||||
{
|
{
|
||||||
|
if (isDesktop())
|
||||||
|
{
|
||||||
|
return openHybridDesktopSink(sessionId, fileName);
|
||||||
|
}
|
||||||
// 注意:直接 navigator.storage?.getDirectory 在 TS 下永远 truthy(optional
|
// 注意:直接 navigator.storage?.getDirectory 在 TS 下永远 truthy(optional
|
||||||
// chaining 取的是函数引用,不会调用)。要真探测必须 try-catch 调用。
|
// chaining 取的是函数引用,不会调用)。要真探测必须 try-catch 调用。
|
||||||
if (typeof navigator !== "undefined" && navigator.storage)
|
if (typeof navigator !== "undefined" && navigator.storage)
|
||||||
@@ -42,6 +74,99 @@ export async function openIncomingSink(sessionId: string): Promise<IncomingSink>
|
|||||||
return openMemorySink();
|
return openMemorySink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面混合有界内存槽。小文件(暂存 < CAP)纯内存累积,close 时返回 Blob 交给
|
||||||
|
* downloadBlob(= 既有桌面整文件过 Go 写盘路径);一旦暂存越过 CAP,转流式落盘——
|
||||||
|
* 把已暂存的全部按 ≤批大小刷给 Go、释放内存,之后每满一批再刷,内存恒定有界。
|
||||||
|
*
|
||||||
|
* write() 由调用方(p2p / relay 的 receiveQueue promise 链)严格串行调用,故内部
|
||||||
|
* 无需再加锁。
|
||||||
|
*/
|
||||||
|
function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSink
|
||||||
|
{
|
||||||
|
let pending: ArrayBuffer[] = [];
|
||||||
|
let pendingBytes = 0;
|
||||||
|
let spilled = false;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
// 从 pending 头部取出至多 DESKTOP_FLUSH_BATCH 字节拼成一块 append 给 Go。
|
||||||
|
// drainAll=true 把剩余全部刷完(spill 起始 / close);否则只在满一批时刷。
|
||||||
|
const flush = async (drainAll: boolean): Promise<void> =>
|
||||||
|
{
|
||||||
|
while (pendingBytes >= DESKTOP_FLUSH_BATCH || (drainAll && pendingBytes > 0))
|
||||||
|
{
|
||||||
|
let take = 0;
|
||||||
|
const batch: ArrayBuffer[] = [];
|
||||||
|
while (pending.length > 0
|
||||||
|
&& (take === 0 || take + pending[0].byteLength <= DESKTOP_FLUSH_BATCH))
|
||||||
|
{
|
||||||
|
const c = pending.shift() as ArrayBuffer;
|
||||||
|
batch.push(c);
|
||||||
|
take += c.byteLength;
|
||||||
|
}
|
||||||
|
pendingBytes -= take;
|
||||||
|
await appendIncomingDownloadDesktop(sessionId, concatChunks(batch, take));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async write(chunk)
|
||||||
|
{
|
||||||
|
if (closed) { return; }
|
||||||
|
// 复制一份:底层 ArrayBuffer 来自 dc.onmessage 的 event.data,异步链上
|
||||||
|
// 保留独立副本避免被回收 / 复用。
|
||||||
|
pending.push(chunk.slice(0));
|
||||||
|
pendingBytes += chunk.byteLength;
|
||||||
|
|
||||||
|
if (!spilled)
|
||||||
|
{
|
||||||
|
if (pendingBytes >= DESKTOP_MEMORY_CAP)
|
||||||
|
{
|
||||||
|
// 越过内存上限:开始流式落盘,把已暂存的全部刷给 Go,转滚动批。
|
||||||
|
spilled = true;
|
||||||
|
await beginIncomingDownloadDesktop(sessionId);
|
||||||
|
await flush(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await flush(false);
|
||||||
|
},
|
||||||
|
async close()
|
||||||
|
{
|
||||||
|
if (closed) { throw new Error("sink already closed"); }
|
||||||
|
closed = true;
|
||||||
|
if (!spilled)
|
||||||
|
{
|
||||||
|
// 小文件:从未落盘,整文件还在内存 → Blob 交给 downloadBlob。
|
||||||
|
return { kind: "blob", blob: new Blob(pending) };
|
||||||
|
}
|
||||||
|
await flush(true);
|
||||||
|
const path = await finalizeIncomingDownloadDesktop(sessionId, fileName);
|
||||||
|
return { kind: "saved", path };
|
||||||
|
},
|
||||||
|
async cancel()
|
||||||
|
{
|
||||||
|
closed = true;
|
||||||
|
pending = [];
|
||||||
|
pendingBytes = 0;
|
||||||
|
if (spilled) { await abortIncomingDownloadDesktop(sessionId); }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// concatChunks 把若干 ArrayBuffer 拼成一个 Uint8Array(供 base64 过桥)。
|
||||||
|
function concatChunks(parts: ArrayBuffer[], totalBytes: number): Uint8Array
|
||||||
|
{
|
||||||
|
const out = new Uint8Array(totalBytes);
|
||||||
|
let off = 0;
|
||||||
|
for (const p of parts)
|
||||||
|
{
|
||||||
|
out.set(new Uint8Array(p), off);
|
||||||
|
off += p.byteLength;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function openOPFSSink(sessionId: string): Promise<IncomingSink>
|
async function openOPFSSink(sessionId: string): Promise<IncomingSink>
|
||||||
{
|
{
|
||||||
const dir = await navigator.storage.getDirectory();
|
const dir = await navigator.storage.getDirectory();
|
||||||
@@ -70,7 +195,10 @@ async function openOPFSSink(sessionId: string): Promise<IncomingSink>
|
|||||||
await writable.close();
|
await writable.close();
|
||||||
const file = await handle.getFile();
|
const file = await handle.getFile();
|
||||||
// 文件在 OPFS 里仍占空间;download 触发后再删(finalizeIncoming 里调)
|
// 文件在 OPFS 里仍占空间;download 触发后再删(finalizeIncoming 里调)
|
||||||
return new Blob([file], { type: file.type || "application/octet-stream" });
|
return {
|
||||||
|
kind: "blob",
|
||||||
|
blob: new Blob([file], { type: file.type || "application/octet-stream" }),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
async cancel()
|
async cancel()
|
||||||
{
|
{
|
||||||
@@ -97,7 +225,7 @@ function openMemorySink(): IncomingSink
|
|||||||
async close()
|
async close()
|
||||||
{
|
{
|
||||||
closed = true;
|
closed = true;
|
||||||
return new Blob(chunks);
|
return { kind: "blob", blob: new Blob(chunks) };
|
||||||
},
|
},
|
||||||
async cancel()
|
async cancel()
|
||||||
{
|
{
|
||||||
@@ -107,6 +235,19 @@ function openMemorySink(): IncomingSink
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliverIncoming 把 sink.close() 的结果落到用户:已落盘(桌面大文件)只提示路径,
|
||||||
|
// 文件已在 Go 侧写好;Blob(浏览器 / 桌面小文件)交给 downloadBlob(浏览器原生下载
|
||||||
|
// / 桌面整文件过 Go 写盘)。p2p / relay 的接收完成路径都走这里。
|
||||||
|
export function deliverIncoming(result: SinkResult, fileName: string, sessionId: string): void
|
||||||
|
{
|
||||||
|
if (result.kind === "saved")
|
||||||
|
{
|
||||||
|
toast.ok(t("transfer.savedTo", { path: result.path }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadBlob(result.blob, fileName, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 触发浏览器下载并把 OPFS 临时文件 + ObjectURL 都安排清理。
|
* 触发浏览器下载并把 OPFS 临时文件 + ObjectURL 都安排清理。
|
||||||
* 60s 给浏览器把 blob 落到用户磁盘的窗口,过短可能导致下载读到一半源被删。
|
* 60s 给浏览器把 blob 落到用户磁盘的窗口,过短可能导致下载读到一半源被删。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiFetch } from "../../net/api";
|
import { apiFetch } from "../../net/api";
|
||||||
import { getICEServers } from "./iceServers";
|
import { getICEServers } from "./iceServers";
|
||||||
import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store";
|
import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store";
|
||||||
import { downloadBlob, openIncomingSink, type IncomingSink } from "./incomingSink";
|
import { deliverIncoming, openIncomingSink, type IncomingSink } from "./incomingSink";
|
||||||
|
|
||||||
// WebRTC client wired to the brief §2 invariants:
|
// WebRTC client wired to the brief §2 invariants:
|
||||||
// - iceServers: pulled from lib/iceServers (Cloudflare Realtime TURN over
|
// - iceServers: pulled from lib/iceServers (Cloudflare Realtime TURN over
|
||||||
@@ -91,6 +91,9 @@ class Session
|
|||||||
// → sender 的 bufferedAmount 永远卡在 HIGH。把 store push 限到 10 Hz。
|
// → sender 的 bufferedAmount 永远卡在 HIGH。把 store push 限到 10 Hz。
|
||||||
private storePushScheduled: number | null = null;
|
private storePushScheduled: number | null = null;
|
||||||
private lastMessageAt = 0;
|
private lastMessageAt = 0;
|
||||||
|
// 发送进度轮询:streamFile 期间每 250ms 刷一次,让"已交付字节"在 backpressure
|
||||||
|
// 等待期间也能平滑爬升、不误报 stalled(见 streamFile / senderDeliveredBytes)。
|
||||||
|
private sendProgressPollId: number | null = null;
|
||||||
|
|
||||||
constructor(public sessionId: string, public peerName: string, public role: "sender" | "receiver")
|
constructor(public sessionId: string, public peerName: string, public role: "sender" | "receiver")
|
||||||
{
|
{
|
||||||
@@ -207,7 +210,7 @@ class Session
|
|||||||
if (!cur) { return; }
|
if (!cur) { return; }
|
||||||
|
|
||||||
const isReceiver = this.role === "receiver";
|
const isReceiver = this.role === "receiver";
|
||||||
const bytes = isReceiver ? this.receivedBytes : this.bytesSent;
|
const bytes = isReceiver ? this.receivedBytes : this.senderDeliveredBytes();
|
||||||
const lastProgressAt = bytes > (cur.bytesTransferred ?? 0)
|
const lastProgressAt = bytes > (cur.bytesTransferred ?? 0)
|
||||||
? Date.now()
|
? Date.now()
|
||||||
: cur.lastProgressAt;
|
: cur.lastProgressAt;
|
||||||
@@ -237,11 +240,44 @@ class Session
|
|||||||
private emitProgress(): void
|
private emitProgress(): void
|
||||||
{
|
{
|
||||||
const total = this.role === "sender" ? this.outgoingTotal : (this.receivedMeta?.size ?? 0);
|
const total = this.role === "sender" ? this.outgoingTotal : (this.receivedMeta?.size ?? 0);
|
||||||
const bytes = this.role === "sender" ? this.bytesSent : this.receivedBytes;
|
const bytes = this.role === "sender" ? this.senderDeliveredBytes() : this.receivedBytes;
|
||||||
for (const cb of this.progressListeners) { cb({ bytes, total }); }
|
for (const cb of this.progressListeners) { cb({ bytes, total }); }
|
||||||
this.throttledPushUpdate();
|
this.throttledPushUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送端"已交付字节"=已离开本地 buffer 的量(bytesSent − bufferedAmount)。
|
||||||
|
* bytesSent 只是"已塞进 dc 的本地 16MiB buffer",会被 64KB 一片秒满到 16MiB 后
|
||||||
|
* 定格;真正送达网络的是离开 buffer 的部分。用它做进度,进度随接收端实际收程
|
||||||
|
* 平滑爬升,而非跳到 buffer 满就冻住,也避免 backpressure 等待期被误判 stalled。
|
||||||
|
* 单调非降:send 时 bytesSent 与 bufferedAmount 同增(delivered 持平),其余时刻
|
||||||
|
* buffer 抽干使 bufferedAmount 降(delivered 升)。
|
||||||
|
*/
|
||||||
|
private senderDeliveredBytes(): number
|
||||||
|
{
|
||||||
|
const buffered = this.dc?.bufferedAmount ?? 0;
|
||||||
|
return Math.max(0, this.bytesSent - buffered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startSendProgressPoll(): void
|
||||||
|
{
|
||||||
|
if (this.sendProgressPollId !== null) { return; }
|
||||||
|
this.sendProgressPollId = window.setInterval(() =>
|
||||||
|
{
|
||||||
|
if (this.canceled) { this.stopSendProgressPoll(); return; }
|
||||||
|
this.emitProgress();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopSendProgressPoll(): void
|
||||||
|
{
|
||||||
|
if (this.sendProgressPollId !== null)
|
||||||
|
{
|
||||||
|
window.clearInterval(this.sendProgressPollId);
|
||||||
|
this.sendProgressPollId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private classifyAndCount(c: RTCIceCandidate | RTCIceCandidateInit, into: CandidateBreakdown): void
|
private classifyAndCount(c: RTCIceCandidate | RTCIceCandidateInit, into: CandidateBreakdown): void
|
||||||
{
|
{
|
||||||
const sdp = (c as RTCIceCandidate).candidate ?? (c as RTCIceCandidateInit).candidate ?? "";
|
const sdp = (c as RTCIceCandidate).candidate ?? (c as RTCIceCandidateInit).candidate ?? "";
|
||||||
@@ -297,8 +333,9 @@ class Session
|
|||||||
if (msg.type === "meta")
|
if (msg.type === "meta")
|
||||||
{
|
{
|
||||||
this.receivedMeta = { name: msg.name, size: msg.size, sha256: msg.sha256 };
|
this.receivedMeta = { name: msg.name, size: msg.size, sha256: msg.sha256 };
|
||||||
// 提前打开 sink;后续 chunk 入队等 sink 就绪
|
// 提前打开 sink;后续 chunk 入队等 sink 就绪。带上文件名,桌面大文件
|
||||||
this.receiveQueue = openIncomingSink(this.sessionId);
|
// spill 落盘后据此命名。
|
||||||
|
this.receiveQueue = openIncomingSink(this.sessionId, msg.name);
|
||||||
this.emitProgress();
|
this.emitProgress();
|
||||||
}
|
}
|
||||||
else if (msg.type === "done")
|
else if (msg.type === "done")
|
||||||
@@ -364,8 +401,8 @@ class Session
|
|||||||
{
|
{
|
||||||
const sink = await this.receiveQueue;
|
const sink = await this.receiveQueue;
|
||||||
if (!sink) { throw new Error("receive sink missing"); }
|
if (!sink) { throw new Error("receive sink missing"); }
|
||||||
const blob = await sink.close();
|
const result = await sink.close();
|
||||||
downloadBlob(blob, this.receivedMeta.name, this.sessionId);
|
deliverIncoming(result, this.receivedMeta.name, this.sessionId);
|
||||||
this.setState("completed");
|
this.setState("completed");
|
||||||
void this.markServerDone(this.receivedBytes);
|
void this.markServerDone(this.receivedBytes);
|
||||||
}
|
}
|
||||||
@@ -433,6 +470,7 @@ class Session
|
|||||||
{
|
{
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error("p2p send failed", e);
|
console.error("p2p send failed", e);
|
||||||
|
this.stopSendProgressPoll();
|
||||||
this.setState("failed");
|
this.setState("failed");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -455,6 +493,11 @@ class Session
|
|||||||
const meta: FileMeta = { name: file.name, size: file.size };
|
const meta: FileMeta = { name: file.name, size: file.size };
|
||||||
dc.send(JSON.stringify({ type: "meta", ...meta }));
|
dc.send(JSON.stringify({ type: "meta", ...meta }));
|
||||||
|
|
||||||
|
// buffer 会被 64KB 一片秒满到 16MiB,之后发送循环长时间阻塞在 waitForBufferLow
|
||||||
|
// 等抽干,期间没有 send、也没有 emitProgress。这个轮询在等待期持续刷新进度,让
|
||||||
|
// "已交付字节"随接收端实际收程平滑爬升、lastProgressAt 保持新鲜,不误报"疑似卡住"。
|
||||||
|
this.startSendProgressPoll();
|
||||||
|
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (offset < file.size)
|
while (offset < file.size)
|
||||||
{
|
{
|
||||||
@@ -501,8 +544,17 @@ class Session
|
|||||||
}
|
}
|
||||||
|
|
||||||
dc.send(JSON.stringify({ type: "done" }));
|
dc.send(JSON.stringify({ type: "done" }));
|
||||||
this.setState("completed");
|
|
||||||
this.setPhase("completing");
|
this.setPhase("completing");
|
||||||
|
// 抽干整个 buffer(数据 + done 帧)再宣告完成:dc.send 只是入本地 buffer,真正
|
||||||
|
// 送达要等 SCTP 发出。早先送完入 buffer 即 setState("completed"),会在还有多达
|
||||||
|
// 16MiB 在途时就标记完成,与接收端"仍在下载"不一致。等 bufferedAmount 落到 0=
|
||||||
|
// 字节已全部交付网络,接收端随后 finalize 并 POST /done(那条权威 /done 仍由接收
|
||||||
|
// 端发,这里只在抽干后驱动发送端自身 UI 收尾)。
|
||||||
|
await waitForBufferLow(dc, 0);
|
||||||
|
this.stopSendProgressPoll();
|
||||||
|
this.bytesSent = file.size;
|
||||||
|
this.emitProgress();
|
||||||
|
this.setState("completed");
|
||||||
// /done is intentionally posted by the *receiver* only — they are the
|
// /done is intentionally posted by the *receiver* only — they are the
|
||||||
// authoritative "got it" signal. Doubling it up here just produces
|
// authoritative "got it" signal. Doubling it up here just produces
|
||||||
// 409 console noise once the receiver's call lands first.
|
// 409 console noise once the receiver's call lands first.
|
||||||
@@ -657,6 +709,7 @@ class Session
|
|||||||
window.clearInterval(this.selectedPairPollId);
|
window.clearInterval(this.selectedPairPollId);
|
||||||
this.selectedPairPollId = null;
|
this.selectedPairPollId = null;
|
||||||
}
|
}
|
||||||
|
this.stopSendProgressPoll();
|
||||||
this.dc?.close();
|
this.dc?.close();
|
||||||
this.pc.close();
|
this.pc.close();
|
||||||
this.setState("closed");
|
this.setState("closed");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiFetch } from "../../net/api";
|
import { apiFetch } from "../../net/api";
|
||||||
import { downloadBlob, openIncomingSink } from "./incomingSink";
|
import { deliverIncoming, openIncomingSink } from "./incomingSink";
|
||||||
|
|
||||||
// Relay path (PROJECT_BRIEF.md §M5/M9). Sender uploads sequential 1 MiB chunks
|
// Relay path (PROJECT_BRIEF.md §M5/M9). Sender uploads sequential 1 MiB chunks
|
||||||
// via POST and receiver streams the response via GET. Streaming POST is NOT
|
// via POST and receiver streams the response via GET. Streaming POST is NOT
|
||||||
@@ -169,7 +169,7 @@ export async function relayReceiveFile(
|
|||||||
expectedSize: number,
|
expectedSize: number,
|
||||||
onProgress?: (p: RelayProgress) => void,
|
onProgress?: (p: RelayProgress) => void,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<Blob>
|
): Promise<void>
|
||||||
{
|
{
|
||||||
const url = `/api/relay/${encodeURIComponent(sessionId)}/stream`;
|
const url = `/api/relay/${encodeURIComponent(sessionId)}/stream`;
|
||||||
const r = await apiFetch(url, { method: "GET", signal: abortSignal });
|
const r = await apiFetch(url, { method: "GET", signal: abortSignal });
|
||||||
@@ -181,7 +181,7 @@ export async function relayReceiveFile(
|
|||||||
if (!r.body) { throw new Error("relay: no response body"); }
|
if (!r.body) { throw new Error("relay: no response body"); }
|
||||||
|
|
||||||
const reader = r.body.getReader();
|
const reader = r.body.getReader();
|
||||||
const sink = await openIncomingSink(sessionId);
|
const sink = await openIncomingSink(sessionId, fileName);
|
||||||
let received = 0;
|
let received = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -207,7 +207,6 @@ export async function relayReceiveFile(
|
|||||||
try { reader.cancel(); } catch { /* noop */ }
|
try { reader.cancel(); } catch { /* noop */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await sink.close();
|
const result = await sink.close();
|
||||||
downloadBlob(blob, fileName, sessionId);
|
deliverIncoming(result, fileName, sessionId);
|
||||||
return blob;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ interface DesktopBridge
|
|||||||
// data 为 base64:Wails 把 JS 字符串作为 JSON 字符串编码,Go 端 []byte 参数按
|
// data 为 base64:Wails 把 JS 字符串作为 JSON 字符串编码,Go 端 []byte 参数按
|
||||||
// base64 解码。返回实际写入的绝对路径。
|
// base64 解码。返回实际写入的绝对路径。
|
||||||
SaveDownload: (name: string, data: string) => Promise<string>;
|
SaveDownload: (name: string, data: string) => Promise<string>;
|
||||||
|
// 流式落盘(接收端混合 sink 越过内存上限后走这条):Begin 开临时文件、Append
|
||||||
|
// 追加批次(base64)、Finalize 改名落定返回绝对路径、Abort 取消时丢弃临时文件。
|
||||||
|
BeginDownload: (sessionId: string) => Promise<void>;
|
||||||
|
AppendDownload: (sessionId: string, data: string) => Promise<void>;
|
||||||
|
FinalizeDownload: (sessionId: string, name: string) => Promise<string>;
|
||||||
|
AbortDownload: (sessionId: string) => Promise<void>;
|
||||||
ChooseDownloadDir: (title: string) => Promise<string>;
|
ChooseDownloadDir: (title: string) => Promise<string>;
|
||||||
EffectiveDownloadDir: () => Promise<string>;
|
EffectiveDownloadDir: () => Promise<string>;
|
||||||
// 弹原生系统通知(macOS UNUserNotificationCenter / Windows toast)。标题、正文
|
// 弹原生系统通知(macOS UNUserNotificationCenter / Windows toast)。标题、正文
|
||||||
@@ -275,6 +281,38 @@ export async function saveIncomingFileDesktop(name: string, blob: Blob): Promise
|
|||||||
return app.SaveDownload(name, b64);
|
return app.SaveDownload(name, b64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 流式落盘封装:接收端混合 sink 越过内存上限后,把溢出批次交给 Go 写入临时文件,
|
||||||
|
// finalize 时改名落定。begin / finalize 失败抛错(由 sink 走失败路径);append 在
|
||||||
|
// 批次边界调用,base64 过桥同 SaveDownload;abort 吞错(取消路径不应再抛)。
|
||||||
|
export async function beginIncomingDownloadDesktop(sessionId: string): Promise<void>
|
||||||
|
{
|
||||||
|
const app = bridge();
|
||||||
|
if (!app) { throw new Error("desktop bridge unavailable"); }
|
||||||
|
await app.BeginDownload(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendIncomingDownloadDesktop(sessionId: string, bytes: Uint8Array): Promise<void>
|
||||||
|
{
|
||||||
|
const app = bridge();
|
||||||
|
if (!app) { throw new Error("desktop bridge unavailable"); }
|
||||||
|
await app.AppendDownload(sessionId, bytesToBase64(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finalizeIncomingDownloadDesktop(sessionId: string, name: string): Promise<string>
|
||||||
|
{
|
||||||
|
const app = bridge();
|
||||||
|
if (!app) { throw new Error("desktop bridge unavailable"); }
|
||||||
|
return app.FinalizeDownload(sessionId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function abortIncomingDownloadDesktop(sessionId: string): Promise<void>
|
||||||
|
{
|
||||||
|
const app = bridge();
|
||||||
|
if (!app) { return; }
|
||||||
|
try { await app.AbortDownload(sessionId); }
|
||||||
|
catch { /* 取消 / 失败路径,吞掉 */ }
|
||||||
|
}
|
||||||
|
|
||||||
// chooseDownloadDir 打开原生目录选择框(标题本地化由调用方传入),返回所选目录;
|
// chooseDownloadDir 打开原生目录选择框(标题本地化由调用方传入),返回所选目录;
|
||||||
// 用户取消返回空串。浏览器里 bridge() 为 null,返回空串。
|
// 用户取消返回空串。浏览器里 bridge() 为 null,返回空串。
|
||||||
export async function chooseDownloadDir(title: string): Promise<string>
|
export async function chooseDownloadDir(title: string): Promise<string>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { startOutgoingTransfer } from "../features/transfer/transfer";
|
|||||||
import { sendMessage } from "../features/messaging/messaging";
|
import { sendMessage } from "../features/messaging/messaging";
|
||||||
import { fetchClipboard, CLIPBOARD_MAX_BYTES } from "../features/clipboard/clipboard";
|
import { fetchClipboard, CLIPBOARD_MAX_BYTES } from "../features/clipboard/clipboard";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { DynText } from "../ui/primitives";
|
||||||
import { toast } from "../ui/feedback";
|
import { toast } from "../ui/feedback";
|
||||||
import s from "./index.module.css";
|
import s from "./index.module.css";
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ function HomePage()
|
|||||||
<Container size="xl" py="lg">
|
<Container size="xl" py="lg">
|
||||||
<div className={s.page}>
|
<div className={s.page}>
|
||||||
<h1 className={s.greeting}>
|
<h1 className={s.greeting}>
|
||||||
{t("home.greeting", { name: user?.name ?? "" })}
|
<DynText>{t("home.greeting", { name: user?.name ?? "" })}</DynText>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<DeviceStrip
|
<DeviceStrip
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
import { useAppStore } from "../store";
|
import { useAppStore } from "../store";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { formatRelative, isAsciiDeviceName } from "../utils/format";
|
import { formatRelative, isAsciiDeviceName } from "../utils/format";
|
||||||
import { Badge, Button, Callout, Panel, TextField } from "../ui/primitives";
|
import { Badge, Button, Callout, DynText, Panel, TextField } from "../ui/primitives";
|
||||||
import { StateDot } from "../ui/glyphs";
|
import { StateDot } from "../ui/glyphs";
|
||||||
import { toast } from "../ui/feedback";
|
import { toast } from "../ui/feedback";
|
||||||
|
|
||||||
@@ -563,7 +563,7 @@ function SessionRow(props: { session: AuthSession; busy: boolean; onRevoke?: ()
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" c="dimmed" truncate>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{onlineLabel} · {kindLabel} · {lastUsedText}
|
<DynText>{onlineLabel}</DynText> · {kindLabel} · <DynText>{lastUsedText}</DynText>
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
{onRevoke && (
|
{onRevoke && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { StateCreator } from "zustand";
|
import type { StateCreator } from "zustand";
|
||||||
import type { AppState } from "../types";
|
import type { AppState, TransferRecord } from "../types";
|
||||||
import { normalizeTerminal } from "../helpers";
|
import { normalizeTerminal } from "../helpers";
|
||||||
|
|
||||||
export type TransferSlice = Pick<
|
export type TransferSlice = Pick<
|
||||||
@@ -7,6 +7,26 @@ export type TransferSlice = Pick<
|
|||||||
"activeTransfers" | "history" | "upsertTransfer" | "completeTransfer" | "removeTransfer"
|
"activeTransfers" | "history" | "upsertTransfer" | "completeTransfer" | "removeTransfer"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
// EMA 平滑系数:瞬时速率抖动大(10Hz 采样 + chunk 边界),取 0.3 让显示稳又不过钝。
|
||||||
|
const RATE_EMA_ALPHA = 0.3;
|
||||||
|
|
||||||
|
// withRate 在记录落库前算出瞬时传输速度。bytesTransferred 与 lastProgressAt 配对——
|
||||||
|
// 后者只在前者增长时刷新为当时墙钟(见 p2p pushUpdate),故相邻两次「有增长」样本的
|
||||||
|
// 差分即瞬时速率,再做 EMA 平滑。无增长(仅 iceStats / phase 等更新)则原样保留上次
|
||||||
|
// 速率(next 由 {...cur} 展开本就带着它),不污染。
|
||||||
|
function withRate(prev: TransferRecord | undefined, next: TransferRecord): TransferRecord
|
||||||
|
{
|
||||||
|
if (!prev) { return next; }
|
||||||
|
const prevBytes = prev.bytesTransferred ?? 0;
|
||||||
|
const nextBytes = next.bytesTransferred ?? 0;
|
||||||
|
const prevAt = prev.lastProgressAt ?? 0;
|
||||||
|
const nextAt = next.lastProgressAt ?? 0;
|
||||||
|
if (nextBytes <= prevBytes || nextAt <= prevAt) { return next; }
|
||||||
|
const instant = (nextBytes - prevBytes) / ((nextAt - prevAt) / 1000);
|
||||||
|
const prevRate = prev.bytesPerSec ?? instant;
|
||||||
|
return { ...next, bytesPerSec: RATE_EMA_ALPHA * instant + (1 - RATE_EMA_ALPHA) * prevRate };
|
||||||
|
}
|
||||||
|
|
||||||
export const createTransferSlice: StateCreator<AppState, [], [], TransferSlice> = (set) =>
|
export const createTransferSlice: StateCreator<AppState, [], [], TransferSlice> = (set) =>
|
||||||
({
|
({
|
||||||
activeTransfers: {},
|
activeTransfers: {},
|
||||||
@@ -16,7 +36,7 @@ export const createTransferSlice: StateCreator<AppState, [], [], TransferSlice>
|
|||||||
{
|
{
|
||||||
set((s) =>
|
set((s) =>
|
||||||
({
|
({
|
||||||
activeTransfers: { ...s.activeTransfers, [t.sessionId]: t },
|
activeTransfers: { ...s.activeTransfers, [t.sessionId]: withRate(s.activeTransfers[t.sessionId], t) },
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ export interface TransferRecord
|
|||||||
bytesTransferred?: number;
|
bytesTransferred?: number;
|
||||||
/** 上一次 bytesTransferred 增长的时刻(ms epoch),UI 据此判 stalled */
|
/** 上一次 bytesTransferred 增长的时刻(ms epoch),UI 据此判 stalled */
|
||||||
lastProgressAt?: number;
|
lastProgressAt?: number;
|
||||||
|
/** 瞬时传输速度(字节/秒,EMA 平滑)。由 upsertTransfer 据相邻样本差分算得,
|
||||||
|
* UI 仅在字节流动且未卡死时展示,避免显示误导性的 0 / 陈旧速率 */
|
||||||
|
bytesPerSec?: number;
|
||||||
iceStats?: IceStats;
|
iceStats?: IceStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// DynText —— 动态 CJK 文本的排版安全包装。
|
||||||
|
//
|
||||||
|
// 聚珍(Juzhen)shim 会把含 CJK 的文本节点 charify 拆成多个 DOM 节点来做中西间距 /
|
||||||
|
// 标点对齐。当这段文本**就地更新**(React 改写同一个文本节点、元素本身仍挂载)时,
|
||||||
|
// shim 先前拆出的旧碎片成了 React 不再追踪的兄弟节点残留下来,每更新一次就累积一个
|
||||||
|
// 多余字——典型如「发送到 X」切换设备后前面多出来的「到」,或每秒 tick 的相对时间。
|
||||||
|
//
|
||||||
|
// 解法:用 key=文本内容。内容一变,React 就把旧 span(连同里面的 shim 碎片)整体卸载、
|
||||||
|
// 挂载一个全新的 span,shim 再对新节点处理一次。既消除累积,又**保留**中西自动间距
|
||||||
|
// (不像 data-jz-skip 那样把整段排版关掉)。代价是内容变化时该 span 会重挂,但只是
|
||||||
|
// 一截短文本、成本可忽略。
|
||||||
|
//
|
||||||
|
// 仅用于会就地变化的动态 CJK chrome(状态文案 / 计数 / 相对时间 / 速度 / 设备名等);
|
||||||
|
// 静态一次性文本不会累积,无需包。children 限定为 string —— key 必须是稳定可比较的
|
||||||
|
// 原始值,且「内容变=key 变」正是触发重挂的依据。
|
||||||
|
export function DynText(props: { children: string })
|
||||||
|
{
|
||||||
|
return <span key={props.children}>{props.children}</span>;
|
||||||
|
}
|
||||||
@@ -21,3 +21,5 @@ export type { TooltipProps } from "./Tooltip";
|
|||||||
|
|
||||||
export { Callout } from "./Callout";
|
export { Callout } from "./Callout";
|
||||||
export type { CalloutProps, CalloutTone } from "./Callout";
|
export type { CalloutProps, CalloutTone } from "./Callout";
|
||||||
|
|
||||||
|
export { DynText } from "./DynText";
|
||||||
|
|||||||
Reference in New Issue
Block a user