传输修复: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")
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} 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 type { ClipboardState } from "../../store";
|
||||
import { t } from "../../i18n";
|
||||
@@ -159,7 +159,7 @@ export function ClipboardPanel(props: ClipboardPanelProps)
|
||||
<div className={s.cloud}>
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaSize}>{sizeText}</span>
|
||||
<span className={s.metaFrom}>{fromText}</span>
|
||||
<span className={s.metaFrom}><DynText>{fromText}</DynText></span>
|
||||
</div>
|
||||
|
||||
{revealed ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState, type DragEvent } from "react";
|
||||
import { Check, ScanLine } from "lucide-react";
|
||||
import { DeviceTypeIcon, StateDot } from "../../ui/glyphs";
|
||||
import type { DeviceKind } from "../../ui/glyphs";
|
||||
import { DynText } from "../../ui/primitives";
|
||||
import type { DeviceInfo } from "../../store";
|
||||
import { formatRelative } from "../../utils/format";
|
||||
import { t } from "../../i18n";
|
||||
@@ -41,7 +42,7 @@ export function DeviceStrip(props: DeviceStripProps)
|
||||
<div>
|
||||
<span className={s.headerTitle}>{t("home.deviceList.title")}</span>
|
||||
<span className={s.headerCount}>
|
||||
{t("home.deviceList.onlineSuffix", { count: onlineCount })}
|
||||
<DynText>{t("home.deviceList.onlineSuffix", { count: onlineCount })}</DynText>
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.headerActions}>
|
||||
@@ -51,9 +52,11 @@ export function DeviceStrip(props: DeviceStripProps)
|
||||
className={s.headerSwitch}
|
||||
onClick={() => setShowOffline((v) => !v)}
|
||||
>
|
||||
{showOffline
|
||||
? t("home.deviceList.hideOffline")
|
||||
: t("home.deviceList.showOffline", { count: offlineCount })}
|
||||
<DynText>
|
||||
{showOffline
|
||||
? t("home.deviceList.hideOffline")
|
||||
: t("home.deviceList.showOffline", { count: offlineCount })}
|
||||
</DynText>
|
||||
</button>
|
||||
)}
|
||||
{onLinkDevice && (
|
||||
@@ -148,9 +151,11 @@ function DevicePort(props: DevicePortProps)
|
||||
<div className={s.meta}>
|
||||
<StateDot tone={device.online ? "online" : "offline"} />
|
||||
<span>
|
||||
{device.online
|
||||
? t("settings.online")
|
||||
: t("settings.lastSeen", { time: formatRelative(device.lastSeen) })}
|
||||
<DynText>
|
||||
{device.online
|
||||
? t("settings.online")
|
||||
: t("settings.lastSeen", { time: formatRelative(device.lastSeen) })}
|
||||
</DynText>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "../../ui/primitives";
|
||||
import { Button, DynText } from "../../ui/primitives";
|
||||
import type { MessageRecord } from "../../store";
|
||||
import { t } from "../../i18n";
|
||||
import { formatRelative } from "../../utils/format";
|
||||
@@ -23,7 +23,7 @@ export function MessageList(props: MessageListProps)
|
||||
<div>
|
||||
<span className={s.title}>{t("home.message.title")}</span>
|
||||
<span className={s.count}>
|
||||
{t("home.message.countSuffix", { count: messages.length })}
|
||||
<DynText>{t("home.message.countSuffix", { count: messages.length })}</DynText>
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClearAll}>
|
||||
@@ -49,7 +49,7 @@ function MessageRow(props: { m: MessageRecord; onDismiss: () => void })
|
||||
return (
|
||||
<div className={cls}>
|
||||
<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>
|
||||
<button
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Copy, KeyRound, Plus } from "lucide-react";
|
||||
import { apiJSON } from "../../net/api";
|
||||
import { t } from "../../i18n";
|
||||
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";
|
||||
|
||||
type ShortcutTokenView = {
|
||||
@@ -233,7 +233,7 @@ function TokenRow(props: { token: ShortcutTokenView; revoking: boolean; onRevoke
|
||||
date: new Date(token.expires_at * 1000).toLocaleDateString(),
|
||||
})}
|
||||
{" · "}
|
||||
{lastUsed}
|
||||
<DynText>{lastUsed}</DynText>
|
||||
</Text>
|
||||
</Stack>
|
||||
{!token.revoked && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState, type DragEvent } from "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 { t } from "../../i18n";
|
||||
import { formatBytes } from "../../utils/format";
|
||||
@@ -33,13 +33,21 @@ export function Composer(props: ComposerProps)
|
||||
|
||||
// 目标缺失时用 muted 提示而非 error 红 —— 提示职责与发送按钮的 disabled
|
||||
// 状态重合时不应双倍渲染告警。允许预输入文件/消息,等用户选完设备再点发送。
|
||||
// 设备名 span 的内容会随切换就地更新,名字含 CJK 时同样会被 shim 拆段累积;key=名字
|
||||
// 让它整体重挂。「发送到 」是常量文本节点、React 从不改写,无需处理。
|
||||
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>;
|
||||
|
||||
const canSendFile = !!targetDevice && !!selectedFile;
|
||||
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 () =>
|
||||
{
|
||||
if (!canSendFile) { return; }
|
||||
@@ -119,9 +127,7 @@ export function Composer(props: ComposerProps)
|
||||
disabled={!canSendFile}
|
||||
onClick={handleSubmitFile}
|
||||
>
|
||||
{targetDevice
|
||||
? t("home.cta.sendTo", { name: targetDevice })
|
||||
: "发送"}
|
||||
{sendLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -132,9 +138,7 @@ export function Composer(props: ComposerProps)
|
||||
disabled={!canSendMsg}
|
||||
onClick={handleSubmitMsg}
|
||||
>
|
||||
{targetDevice
|
||||
? t("home.cta.sendTo", { name: targetDevice })
|
||||
: "发送"}
|
||||
{sendLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TransferPhase, TransferRecord } from "../../store";
|
||||
import { useAppStore } from "../../store";
|
||||
import { t, type TranslationKey } from "../../i18n";
|
||||
import { formatBytes } from "../../utils/format";
|
||||
import { DynText } from "../../ui/primitives";
|
||||
import { cancelTransfer, skipWaitRelay } from "./transfer";
|
||||
import { TransferDetails } from "./TransferDetails";
|
||||
import s from "./TransferRow.module.css";
|
||||
@@ -45,6 +46,8 @@ export function TransferRow(props: TransferRowProps)
|
||||
&& FLOW_PHASES.has(tr.phase)
|
||||
&& sinceMs >= STALLED_THRESHOLD_MS;
|
||||
|
||||
const status = userFriendlyStatus(tr, stalled, sinceMs);
|
||||
const speed = speedText(tr, stalled);
|
||||
const pct = computeProgress(tr);
|
||||
const failed = tr.state === "FAILED";
|
||||
const done = tr.state === "DONE";
|
||||
@@ -92,13 +95,22 @@ export function TransferRow(props: TransferRowProps)
|
||||
</div>
|
||||
|
||||
<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) && (
|
||||
<>
|
||||
<span className={s.dot}>·</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 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
|
||||
{
|
||||
if (tr.state === "DONE") { return 100; }
|
||||
|
||||
@@ -1,33 +1,65 @@
|
||||
// 接收端的字节落地槽,用来避免把整个文件累积到 JS 堆。
|
||||
//
|
||||
// 背景:iOS Safari 单 tab 内存预算紧;超过后系统会节流 / 暂停 JS 执行,
|
||||
// 导致 SCTP 不再 ack → sender bufferedAmount 撑满 → 死锁(用户实测 24.4 MB
|
||||
// 卡住的根因)。改成流式写入 Origin Private File System(OPFS)后内存只占
|
||||
// 一个 chunk,iOS 16+ / Chrome / Firefox 都已支持 createWritable。
|
||||
// 三条路径,按运行环境选:
|
||||
// - 浏览器(含 iOS PWA):流式写 Origin Private File System(OPFS),内存只占
|
||||
// 一个 chunk。iOS Safari 单 tab 内存预算紧,超过即被系统节流 / 暂停 JS → SCTP
|
||||
// 不再 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 { isDesktop, saveIncomingFileDesktop } from "../../net/desktop";
|
||||
import {
|
||||
abortIncomingDownloadDesktop,
|
||||
appendIncomingDownloadDesktop,
|
||||
beginIncomingDownloadDesktop,
|
||||
finalizeIncomingDownloadDesktop,
|
||||
isDesktop,
|
||||
saveIncomingFileDesktop,
|
||||
} from "../../net/desktop";
|
||||
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
|
||||
{
|
||||
/** 顺序写一个 chunk;返回的 Promise 只有完全落盘后才 resolve,自然 backpressure */
|
||||
write(chunk: ArrayBuffer): Promise<void>;
|
||||
/** 收到 done 帧后调用:finalize + 取出最终 Blob 给浏览器下载 */
|
||||
close(): Promise<Blob>;
|
||||
/** 失败 / 取消路径上调用,确保 OPFS 临时文件被删 */
|
||||
/** 收到 done 帧后调用:finalize 并返回最终结果(Blob 或已落盘路径) */
|
||||
close(): Promise<SinkResult>;
|
||||
/** 失败 / 取消路径上调用,确保 OPFS / 流式临时文件被删 */
|
||||
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
|
||||
// chaining 取的是函数引用,不会调用)。要真探测必须 try-catch 调用。
|
||||
if (typeof navigator !== "undefined" && navigator.storage)
|
||||
@@ -42,6 +74,99 @@ export async function openIncomingSink(sessionId: string): Promise<IncomingSink>
|
||||
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>
|
||||
{
|
||||
const dir = await navigator.storage.getDirectory();
|
||||
@@ -70,7 +195,10 @@ async function openOPFSSink(sessionId: string): Promise<IncomingSink>
|
||||
await writable.close();
|
||||
const file = await handle.getFile();
|
||||
// 文件在 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()
|
||||
{
|
||||
@@ -97,7 +225,7 @@ function openMemorySink(): IncomingSink
|
||||
async close()
|
||||
{
|
||||
closed = true;
|
||||
return new Blob(chunks);
|
||||
return { kind: "blob", blob: new Blob(chunks) };
|
||||
},
|
||||
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 都安排清理。
|
||||
* 60s 给浏览器把 blob 落到用户磁盘的窗口,过短可能导致下载读到一半源被删。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiFetch } from "../../net/api";
|
||||
import { getICEServers } from "./iceServers";
|
||||
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:
|
||||
// - iceServers: pulled from lib/iceServers (Cloudflare Realtime TURN over
|
||||
@@ -91,6 +91,9 @@ class Session
|
||||
// → sender 的 bufferedAmount 永远卡在 HIGH。把 store push 限到 10 Hz。
|
||||
private storePushScheduled: number | null = null;
|
||||
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")
|
||||
{
|
||||
@@ -207,7 +210,7 @@ class Session
|
||||
if (!cur) { return; }
|
||||
|
||||
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)
|
||||
? Date.now()
|
||||
: cur.lastProgressAt;
|
||||
@@ -237,11 +240,44 @@ class Session
|
||||
private emitProgress(): void
|
||||
{
|
||||
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 }); }
|
||||
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
|
||||
{
|
||||
const sdp = (c as RTCIceCandidate).candidate ?? (c as RTCIceCandidateInit).candidate ?? "";
|
||||
@@ -297,8 +333,9 @@ class Session
|
||||
if (msg.type === "meta")
|
||||
{
|
||||
this.receivedMeta = { name: msg.name, size: msg.size, sha256: msg.sha256 };
|
||||
// 提前打开 sink;后续 chunk 入队等 sink 就绪
|
||||
this.receiveQueue = openIncomingSink(this.sessionId);
|
||||
// 提前打开 sink;后续 chunk 入队等 sink 就绪。带上文件名,桌面大文件
|
||||
// spill 落盘后据此命名。
|
||||
this.receiveQueue = openIncomingSink(this.sessionId, msg.name);
|
||||
this.emitProgress();
|
||||
}
|
||||
else if (msg.type === "done")
|
||||
@@ -364,8 +401,8 @@ class Session
|
||||
{
|
||||
const sink = await this.receiveQueue;
|
||||
if (!sink) { throw new Error("receive sink missing"); }
|
||||
const blob = await sink.close();
|
||||
downloadBlob(blob, this.receivedMeta.name, this.sessionId);
|
||||
const result = await sink.close();
|
||||
deliverIncoming(result, this.receivedMeta.name, this.sessionId);
|
||||
this.setState("completed");
|
||||
void this.markServerDone(this.receivedBytes);
|
||||
}
|
||||
@@ -433,6 +470,7 @@ class Session
|
||||
{
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("p2p send failed", e);
|
||||
this.stopSendProgressPoll();
|
||||
this.setState("failed");
|
||||
});
|
||||
};
|
||||
@@ -455,6 +493,11 @@ class Session
|
||||
const meta: FileMeta = { name: file.name, size: file.size };
|
||||
dc.send(JSON.stringify({ type: "meta", ...meta }));
|
||||
|
||||
// buffer 会被 64KB 一片秒满到 16MiB,之后发送循环长时间阻塞在 waitForBufferLow
|
||||
// 等抽干,期间没有 send、也没有 emitProgress。这个轮询在等待期持续刷新进度,让
|
||||
// "已交付字节"随接收端实际收程平滑爬升、lastProgressAt 保持新鲜,不误报"疑似卡住"。
|
||||
this.startSendProgressPoll();
|
||||
|
||||
let offset = 0;
|
||||
while (offset < file.size)
|
||||
{
|
||||
@@ -501,8 +544,17 @@ class Session
|
||||
}
|
||||
|
||||
dc.send(JSON.stringify({ type: "done" }));
|
||||
this.setState("completed");
|
||||
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
|
||||
// authoritative "got it" signal. Doubling it up here just produces
|
||||
// 409 console noise once the receiver's call lands first.
|
||||
@@ -657,6 +709,7 @@ class Session
|
||||
window.clearInterval(this.selectedPairPollId);
|
||||
this.selectedPairPollId = null;
|
||||
}
|
||||
this.stopSendProgressPoll();
|
||||
this.dc?.close();
|
||||
this.pc.close();
|
||||
this.setState("closed");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
// via POST and receiver streams the response via GET. Streaming POST is NOT
|
||||
@@ -169,7 +169,7 @@ export async function relayReceiveFile(
|
||||
expectedSize: number,
|
||||
onProgress?: (p: RelayProgress) => void,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Blob>
|
||||
): Promise<void>
|
||||
{
|
||||
const url = `/api/relay/${encodeURIComponent(sessionId)}/stream`;
|
||||
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"); }
|
||||
|
||||
const reader = r.body.getReader();
|
||||
const sink = await openIncomingSink(sessionId);
|
||||
const sink = await openIncomingSink(sessionId, fileName);
|
||||
let received = 0;
|
||||
try
|
||||
{
|
||||
@@ -207,7 +207,6 @@ export async function relayReceiveFile(
|
||||
try { reader.cancel(); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
const blob = await sink.close();
|
||||
downloadBlob(blob, fileName, sessionId);
|
||||
return blob;
|
||||
const result = await sink.close();
|
||||
deliverIncoming(result, fileName, sessionId);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ interface DesktopBridge
|
||||
// data 为 base64:Wails 把 JS 字符串作为 JSON 字符串编码,Go 端 []byte 参数按
|
||||
// base64 解码。返回实际写入的绝对路径。
|
||||
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>;
|
||||
EffectiveDownloadDir: () => Promise<string>;
|
||||
// 弹原生系统通知(macOS UNUserNotificationCenter / Windows toast)。标题、正文
|
||||
@@ -275,6 +281,38 @@ export async function saveIncomingFileDesktop(name: string, blob: Blob): Promise
|
||||
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 打开原生目录选择框(标题本地化由调用方传入),返回所选目录;
|
||||
// 用户取消返回空串。浏览器里 bridge() 为 null,返回空串。
|
||||
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 { fetchClipboard, CLIPBOARD_MAX_BYTES } from "../features/clipboard/clipboard";
|
||||
import { t } from "../i18n";
|
||||
import { DynText } from "../ui/primitives";
|
||||
import { toast } from "../ui/feedback";
|
||||
import s from "./index.module.css";
|
||||
|
||||
@@ -89,7 +90,7 @@ function HomePage()
|
||||
<Container size="xl" py="lg">
|
||||
<div className={s.page}>
|
||||
<h1 className={s.greeting}>
|
||||
{t("home.greeting", { name: user?.name ?? "" })}
|
||||
<DynText>{t("home.greeting", { name: user?.name ?? "" })}</DynText>
|
||||
</h1>
|
||||
|
||||
<DeviceStrip
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { useAppStore } from "../store";
|
||||
import { t } from "../i18n";
|
||||
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 { toast } from "../ui/feedback";
|
||||
|
||||
@@ -563,7 +563,7 @@ function SessionRow(props: { session: AuthSession; busy: boolean; onRevoke?: ()
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{onlineLabel} · {kindLabel} · {lastUsedText}
|
||||
<DynText>{onlineLabel}</DynText> · {kindLabel} · <DynText>{lastUsedText}</DynText>
|
||||
</Text>
|
||||
</Stack>
|
||||
{onRevoke && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../types";
|
||||
import type { AppState, TransferRecord } from "../types";
|
||||
import { normalizeTerminal } from "../helpers";
|
||||
|
||||
export type TransferSlice = Pick<
|
||||
@@ -7,6 +7,26 @@ export type TransferSlice = Pick<
|
||||
"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) =>
|
||||
({
|
||||
activeTransfers: {},
|
||||
@@ -16,7 +36,7 @@ export const createTransferSlice: StateCreator<AppState, [], [], TransferSlice>
|
||||
{
|
||||
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 增长的时刻(ms epoch),UI 据此判 stalled */
|
||||
lastProgressAt?: number;
|
||||
/** 瞬时传输速度(字节/秒,EMA 平滑)。由 upsertTransfer 据相邻样本差分算得,
|
||||
* UI 仅在字节流动且未卡死时展示,避免显示误导性的 0 / 陈旧速率 */
|
||||
bytesPerSec?: number;
|
||||
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 type { CalloutProps, CalloutTone } from "./Callout";
|
||||
|
||||
export { DynText } from "./DynText";
|
||||
|
||||
Reference in New Issue
Block a user