package platform import ( "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" ) // SaveDownload writes a received file's bytes into dir (empty → system Downloads), // returning the absolute path written. The name comes from a transfer peer (U2, // untrusted), so it is sanitized to a bare filename before use — no path // components survive, so a crafted name like "../../x" cannot escape dir. On a // name collision a " (n)" suffix is appended rather than overwriting. After a // successful write the file is best-effort revealed in the OS file manager. // // If the configured dir is unwritable (deleted, no permission, read-only), the // write is retried into the system Downloads dir before giving up — the WebView // has no usable download fallback (WKWebView ignores blob a[download]), so a // received file must never be silently lost to a bad setting. The returned path // reflects where the file actually landed, so the UI can show it. func SaveDownload(dir, name string, data []byte) (string, error) { if dir == "" { dir = DefaultDownloadDir() } path, err := writeInto(dir, name, data) if err == nil { return path, nil } if def := DefaultDownloadDir(); def != dir { if p, ferr := writeInto(def, name, data); ferr == nil { return p, nil } } return "", err // report the original (configured-dir) failure } // writeInto sanitizes the name, resolves a non-colliding path under dir, writes // the bytes, and reveals the result. Returns the absolute path written. func writeInto(dir, name string, data []byte) (string, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } safe := sanitizeFileName(name) if safe == "" { safe = "cdrop-download" } target := uniquePath(dir, safe) if err := os.WriteFile(target, data, 0o644); err != nil { return "", err } revealInFileManager(target) return target, nil } // sanitizeFileName reduces a peer-supplied name to a single safe filename: // directory components are dropped (path-traversal defense), control chars are // removed, and characters reserved by common filesystems are replaced with "_". func sanitizeFileName(name string) string { name = filepath.Base(filepath.FromSlash(name)) // strip any directory parts name = strings.TrimSpace(name) if name == "." || name == ".." { return "" } var b strings.Builder for _, r := range name { switch { case r < 0x20: continue // control characters case strings.ContainsRune(`/\:*?"<>|`, r): b.WriteRune('_') // filesystem-reserved default: b.WriteRune(r) } } out := strings.TrimSpace(b.String()) if len(out) > 200 { out = strings.TrimSpace(out[:200]) } return out } // uniquePath returns dir/name, or dir/"name (n).ext" with the lowest n that does // not yet exist, so a repeated transfer never silently overwrites a prior file. func uniquePath(dir, name string) string { target := filepath.Join(dir, name) if _, err := os.Stat(target); os.IsNotExist(err) { return target } ext := filepath.Ext(name) stem := strings.TrimSuffix(name, ext) for i := 1; ; i += 1 { cand := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext)) if _, err := os.Stat(cand); os.IsNotExist(err) { return cand } } } // 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() case "windows": _ = 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) }