Support sending folders, not just single files

The sender only handled regular files: a staged directory would fail at
upload time, so users could only send individual files.

Now a staged directory is walked recursively and each file is advertised
with a name relative to the folder's parent (e.g. "Trip/day1/img.jpg"),
which is the LocalSend-compatible way to carry structure. The receiver
recreates those subdirectories under the receive dir, creating parents as
needed. The path-traversal guard is preserved: names are cleaned against a
leading "/" to collapse "..", and a containment check ensures the result
stays within the receive dir.

In the TUI send picker, "a" stages the folder currently being browsed;
staged folders are tagged in the panel and the help text is updated.

Tests: directory expansion produces relative names; an end-to-end folder
send recreates the structure on the receiver; destPath preserves subdirs,
rejects traversal, and de-duplicates within subfolders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-05-27 21:00:52 +01:00
parent f69d3464a7
commit beb6b9040e
6 changed files with 301 additions and 32 deletions

View file

@ -12,8 +12,9 @@ LocalSend mobile and desktop apps on the same LAN, including their default
`/register` handshake, with peer aging.
- **Receive** — incoming files are accepted via a prompt (or auto-accepted) and
written to the receive directory, with live progress.
- **Send** — pick a peer, stage files with a built-in file picker, and upload
them with progress, rate and ETA.
- **Send** — pick a peer, stage files (or whole folders) with a built-in file
picker, and upload them with progress, rate and ETA. Folders are sent
recursively with their structure preserved on the receiver.
- **Manage** — browse the receive folder, mark received files (or whole folders)
and delete the ones you no longer want, behind a confirmation prompt.
- **HTTPS** — generates a self-signed certificate whose fingerprint matches the
@ -92,7 +93,7 @@ omarchy-send --auto-accept --pin 2468
- `1`/`2`/`3`/`4` or `tab` — switch between Devices / Transfers / Manage / Settings
- Peers: `enter` send to the selected peer · `r` refresh · `/` filter
- Send picker: `enter` stage a file · `backspace` unstage · `S` send · `esc` back
- Send picker: `enter` stage a file · `a` add the current folder · `backspace` unstage · `S` send · `esc` back
- Incoming prompt: `y` accept · `n` reject
- Transfers: `c` clear finished
- Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one

View file

@ -11,6 +11,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"mime"
"net"
"net/http"
@ -72,23 +73,21 @@ func (s *Sender) Send(peer discovery.Peer, paths []string, pin string) {
}
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
// Build file metadata keyed by a generated fileId.
files := make(map[string]protocol.FileMetadata, len(paths))
pathByID := make(map[string]string, len(paths))
for _, p := range paths {
fi, err := os.Stat(p)
if err != nil {
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(p), Err: err})
continue
}
// Expand any directories into their files, then build metadata keyed by a
// generated fileId. A directory's files carry a relative FileName (e.g.
// "Trip/day1/img.jpg") so the receiver can recreate the folder structure.
items := s.expand(paths)
files := make(map[string]protocol.FileMetadata, len(items))
pathByID := make(map[string]string, len(items))
for _, it := range items {
id := randID()
files[id] = protocol.FileMetadata{
ID: id,
FileName: filepath.Base(p),
Size: fi.Size(),
FileType: mimeType(p),
FileName: it.name,
Size: it.size,
FileType: mimeType(it.path),
}
pathByID[id] = p
pathByID[id] = it.path
}
if len(files) == 0 {
return
@ -124,6 +123,57 @@ func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
}
}
// fileItem is one concrete file to upload: its path on disk, the relative name
// advertised to the peer (carries folder structure), and its size.
type fileItem struct {
path string
name string
size int64
}
// expand turns the selected paths into a flat list of files. A regular file is
// passed through with its base name. A directory is walked recursively; each
// contained file's advertised name is its path relative to the directory's
// parent, so the selected folder itself is recreated on the receiver (selecting
// "Trip" yields "Trip/day1/img.jpg", …). Unreadable entries are skipped with an
// error event rather than aborting the whole transfer.
func (s *Sender) expand(paths []string) []fileItem {
var items []fileItem
for _, p := range paths {
fi, err := os.Stat(p)
if err != nil {
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(p), Err: err})
continue
}
if !fi.IsDir() {
items = append(items, fileItem{path: p, name: filepath.Base(p), size: fi.Size()})
continue
}
root := filepath.Dir(filepath.Clean(p)) // parent, so the folder name is kept
_ = filepath.WalkDir(p, func(fp string, d fs.DirEntry, err error) error {
if err != nil {
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(fp), Err: err})
return nil // skip this entry, keep walking the rest
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(fp), Err: err})
return nil
}
rel, err := filepath.Rel(root, fp)
if err != nil {
rel = filepath.Base(fp)
}
items = append(items, fileItem{path: fp, name: filepath.ToSlash(rel), size: info.Size()})
return nil
})
}
return items
}
func (s *Sender) prepareUpload(base string, files map[string]protocol.FileMetadata, pin string) (protocol.PrepareUploadResponse, error) {
reqBody, _ := json.Marshal(protocol.PrepareUploadRequest{Info: s.selfCopy(), Files: files})
url := base + protocol.PathPrepareUpload

View file

@ -0,0 +1,124 @@
package client
import (
"context"
"os"
"path/filepath"
"sort"
"testing"
"time"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
"omarchy-send/internal/server"
"omarchy-send/internal/transfer"
)
// expand must walk a directory and advertise each file with a name relative to
// the selected folder's parent, so the folder itself is part of the path.
func TestExpandDirectory(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "Trip")
mustWrite(t, filepath.Join(dir, "cover.jpg"), "a")
mustWrite(t, filepath.Join(dir, "day1", "img.jpg"), "bb")
mustWrite(t, filepath.Join(dir, "day1", "notes.txt"), "ccc")
s := New(protocol.DeviceInfo{})
items := s.expand([]string{dir})
got := make([]string, len(items))
for i, it := range items {
got[i] = it.name
}
sort.Strings(got)
want := []string{"Trip/cover.jpg", "Trip/day1/img.jpg", "Trip/day1/notes.txt"}
if len(got) != len(want) {
t.Fatalf("expand returned %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("expand[%d] = %q, want %q (all: %v)", i, got[i], want[i], got)
}
}
}
// A regular file path passes through with just its base name.
func TestExpandFile(t *testing.T) {
p := filepath.Join(t.TempDir(), "single.bin")
mustWrite(t, p, "x")
items := New(protocol.DeviceInfo{}).expand([]string{p})
if len(items) != 1 || items[0].name != "single.bin" {
t.Fatalf("expand file = %+v", items)
}
}
// End-to-end: sending a directory recreates its structure on the receiver.
func TestSendDirectoryPreservesStructure(t *testing.T) {
recvDir := t.TempDir()
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53998, Protocol: "http",
}
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: true})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
go func() {
for range srv.Transfers() {
}
}()
time.Sleep(50 * time.Millisecond)
srcRoot := t.TempDir()
dir := filepath.Join(srcRoot, "Trip")
files := map[string]string{
"cover.jpg": "cover-bytes",
"day1/img.jpg": "day1-image",
"day2/clip.mov": "day2-clip",
"day2/sub/note.md": "nested-note",
}
for rel, body := range files {
mustWrite(t, filepath.Join(dir, filepath.FromSlash(rel)), body)
}
sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
sender.Send(peer, []string{dir}, "")
done := 0
deadline := time.After(5 * time.Second)
for done < len(files) {
select {
case ev := <-sender.Events():
if ev.Kind == transfer.Error {
t.Fatalf("send error: %v", ev.Err)
}
if ev.Kind == transfer.FileDone {
done++
}
case <-deadline:
t.Fatalf("timed out after %d/%d files", done, len(files))
}
}
for rel, body := range files {
got, err := os.ReadFile(filepath.Join(recvDir, "Trip", filepath.FromSlash(rel)))
if err != nil {
t.Fatalf("missing received %q: %v", rel, err)
}
if string(got) != body {
t.Fatalf("%q content = %q, want %q", rel, got, body)
}
}
}
func mustWrite(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}

View file

@ -0,0 +1,64 @@
package server
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDestPathPreservesSubdirs(t *testing.T) {
dir := t.TempDir()
got, err := destPath(dir, "Trip/day1/img.jpg")
if err != nil {
t.Fatal(err)
}
want := filepath.Join(dir, "Trip", "day1", "img.jpg")
if got != want {
t.Fatalf("destPath = %q, want %q", got, want)
}
// Parent directories must already exist so the writer can create the file.
if _, err := os.Stat(filepath.Dir(got)); err != nil {
t.Fatalf("parent dir not created: %v", err)
}
}
// Traversal attempts must be neutralised — the result always stays under dir.
func TestDestPathRejectsTraversal(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{
"../escape.txt",
"../../etc/passwd",
"a/../../../oops.txt",
"/abs/path.txt",
} {
got, err := destPath(dir, name)
if err != nil {
continue // rejected outright — also acceptable
}
if got != dir && !strings.HasPrefix(got, dir+string(os.PathSeparator)) {
t.Fatalf("destPath(%q) = %q escaped %q", name, got, dir)
}
}
}
// A name colliding with an existing file gets a " (n)" suffix; subdir collisions
// are resolved within their own directory.
func TestDestPathDeDup(t *testing.T) {
dir := t.TempDir()
first, _ := destPath(dir, "sub/file.txt")
if err := os.WriteFile(first, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
second, err := destPath(dir, "sub/file.txt")
if err != nil {
t.Fatal(err)
}
if second == first {
t.Fatalf("expected a de-duplicated path, got %q twice", second)
}
want := filepath.Join(dir, "sub", "file (1).txt")
if second != want {
t.Fatalf("dedup = %q, want %q", second, want)
}
}

View file

@ -12,6 +12,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
@ -251,10 +252,10 @@ func (s *Server) writeFile(sess *session, fe *fileEntry, key string, r io.Reader
s.mu.Lock()
dir := s.receiveDir
s.mu.Unlock()
if err := os.MkdirAll(dir, 0o755); err != nil {
dest, err := destPath(dir, fe.meta.FileName)
if err != nil {
return "", err
}
dest := uniquePath(dir, fe.meta.FileName)
tmp := dest + ".part"
f, err := os.Create(tmp)
@ -297,22 +298,40 @@ func (s *Server) handleCancel(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// uniquePath returns a non-colliding path in dir for the (sanitised) filename.
func uniquePath(dir, name string) string {
base := filepath.Base(filepath.Clean("/" + name)) // strip any path components / traversal
if base == "." || base == "/" || base == "" {
base = "file"
// destPath resolves a safe, non-colliding path under dir for the (possibly
// nested) filename, creating parent directories. Sub-paths are honoured so a
// folder send recreates its structure, but any traversal is neutralised:
// cleaning against a leading "/" collapses ".." at the root, and a final
// containment check guarantees the result stays within dir.
func destPath(dir, name string) (string, error) {
rel := strings.TrimPrefix(filepath.Clean("/"+filepath.ToSlash(name)), "/")
if rel == "" || rel == "." {
rel = "file"
}
candidate := filepath.Join(dir, base)
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
full := filepath.Join(dir, filepath.FromSlash(rel))
if full != dir && !strings.HasPrefix(full, dir+string(os.PathSeparator)) {
return "", fmt.Errorf("unsafe destination for %q", name)
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return "", err
}
return uniqueAt(full), nil
}
// uniqueAt returns full if free, otherwise inserts " (n)" before the extension
// until it finds an unused name in the same directory.
func uniqueAt(full string) string {
if _, err := os.Stat(full); os.IsNotExist(err) {
return full
}
d := filepath.Dir(full)
base := filepath.Base(full)
ext := filepath.Ext(base)
stem := base[:len(base)-len(ext)]
for i := 1; ; i++ {
candidate = filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext))
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
cand := filepath.Join(d, fmt.Sprintf("%s (%d)%s", stem, i, ext))
if _, err := os.Stat(cand); os.IsNotExist(err) {
return cand
}
}
}

View file

@ -358,6 +358,13 @@ func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "ctrl+c", "q":
m.quitting = true
return m, tea.Quit
case "a":
// Stage the folder currently being browsed; it is expanded into its
// files (structure preserved) when the transfer starts.
if dir := m.picker.CurrentDirectory; dir != "" && !contains(m.staged, dir) {
m.staged = append(m.staged, dir)
}
return m, nil
case "backspace":
if len(m.staged) > 0 {
m.staged = m.staged[:len(m.staged)-1]
@ -686,12 +693,16 @@ func (m Model) stagedPanel() string {
BorderForeground(muted).
Padding(0, 1)
if len(m.staged) == 0 {
return border.Render(headerStyle.Render("No files staged — press enter on a file to add it."))
return border.Render(headerStyle.Render("Nothing staged — enter adds a file, a adds the current folder."))
}
var b strings.Builder
b.WriteString(titleStyle.Render(fmt.Sprintf("Staged · %d", len(m.staged))))
for _, p := range m.staged {
b.WriteString("\n" + valueStyle.Render("• "+collapseHome(p)))
label := collapseHome(p)
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
label += "/ (folder)"
}
b.WriteString("\n" + valueStyle.Render("• "+label))
}
return border.Render(b.String())
}
@ -961,7 +972,7 @@ func (m Model) footerText() string {
case m.editing:
return "tab/↑↓ move · enter next · ctrl+s save · esc cancel"
case m.screen == screenPicker:
return "enter stage · backspace unstage · S send · esc back"
return "enter stage file · a add folder · backspace unstage · S send · esc back"
case m.screen == screenPeers:
return "enter send-to · r refresh · / filter · 1-4 switch · q quit"
case m.screen == screenTransfers: