diff --git a/desktop/app.go b/desktop/app.go
index 58ba6f7..27c8523 100644
--- a/desktop/app.go
+++ b/desktop/app.go
@@ -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.
diff --git a/desktop/platform/download.go b/desktop/platform/download.go
index 7fcad34..9167602 100644
--- a/desktop/platform/download.go
+++ b/desktop/platform/download.go
@@ -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)
+}
diff --git a/desktop/platform/download_stream_test.go b/desktop/platform/download_stream_test.go
new file mode 100644
index 0000000..eff0f10
--- /dev/null
+++ b/desktop/platform/download_stream_test.go
@@ -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")
+ }
+}
diff --git a/web/src/features/clipboard/ClipboardPanel.tsx b/web/src/features/clipboard/ClipboardPanel.tsx
index 4b44ff2..9720085 100644
--- a/web/src/features/clipboard/ClipboardPanel.tsx
+++ b/web/src/features/clipboard/ClipboardPanel.tsx
@@ -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)
{sizeText}
- {fromText}
+ {fromText}
{revealed ? (
diff --git a/web/src/features/devices/DeviceStrip.tsx b/web/src/features/devices/DeviceStrip.tsx
index 6e4ae7c..f2d519d 100644
--- a/web/src/features/devices/DeviceStrip.tsx
+++ b/web/src/features/devices/DeviceStrip.tsx
@@ -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)