commit 2dd81700c0ed208139c8e209d861dfc6dd520ae1 Author: 28allday Date: Wed May 27 19:26:08 2026 +0100 Initial commit: Omarchy-Send v0.1.0 LocalSend-compatible file-transfer TUI for headless Arch/Omarchy servers. - Pure-stdlib implementation of the LocalSend v2 protocol (discovery, HTTPS with matching cert fingerprint, send/receive, PIN). - Bubble Tea TUI: Devices, Transfers, Manage (received-file housekeeping) and Settings, theme-aware on Omarchy. - Dual-mode install.sh: curl-pipe download or build-from-clone. Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8331d53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/omarchy-send +/dist/omarchy-send-linux-* +*.tmp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5acd172 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gavin Nugent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bef7da --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# Omarchy-Send (`omarchy-send`) + +A [LocalSend](https://localsend.org)-compatible file-transfer client with a +**terminal UI**, built for headless Arch / Omarchy servers used over SSH — no +desktop environment, no clipboard, no browser. It interoperates with the stock +LocalSend mobile and desktop apps on the same LAN, including their default +**encrypted (HTTPS)** mode. + +## Features + +- **Discovery** — multicast announce/listen on `224.0.0.167:53317` plus the HTTP + `/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. +- **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 + scheme the official client pins (uppercase-hex SHA-256 of the cert DER), so + stock encrypted peers talk to it with no configuration. +- **Single static binary**, pure-stdlib protocol layer; only the Charm TUI + libraries are external dependencies. + +## Install + +One line, nothing to clone: + +```sh +curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install.sh | bash +``` + +This downloads the right binary for your architecture into `~/.local/bin`, and on +Omarchy also adds a floating Walker entry (search **Omarchy-Send**). Override the +location with `BIN_DIR=/usr/local/bin`, or pin a version with +`OMARCHY_SEND_VERSION=v0.1.0`. + +> The installer is a short shell script fetched over HTTPS; read it first if you +> prefer — it lives at [`install.sh`](install.sh) in this repo. + +## Build from source + +```sh +git clone https://github.com/28allday/omarchy-send +cd omarchy-send +go build -o omarchy-send ./cmd/omarchy-send # or: ./install.sh +``` + +Run from a clone, `./install.sh` builds with your local Go toolchain instead of +downloading. + +## Usage + +```sh +omarchy-send # uses config / sensible defaults +omarchy-send --alias my-server # override the advertised name for this run +omarchy-send --port 53317 # override the listen port +omarchy-send --dir ~/Downloads # override the receive directory +omarchy-send --auto-accept # accept incoming transfers without a prompt +omarchy-send --pin 2468 # require senders to supply this PIN +omarchy-send --no-icons # drop Nerd Font glyphs (non-Nerd-Font terminals) +``` + +### Theming + +On Omarchy, the TUI reads the active theme's `~/.config/omarchy/current/theme/colors.toml` +and matches it. Elsewhere (headless / over SSH) it falls back to **ANSI palette +colours**, so it tracks whatever colour scheme the connecting terminal uses. + +Config (including the generated TLS identity) is stored at +`~/.config/omarchy-send/config.json`. Received files default to `~/Omarchy-Send/`. + +### Unattended / headless mode + +For a server that should accept files without anyone at the keyboard, combine +auto-accept with a PIN so only senders who know the code can push to it: + +```sh +omarchy-send --auto-accept --pin 2468 +``` + +### Keys + +- `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 +- Incoming prompt: `y` accept · `n` reject +- Transfers: `c` clear finished +- Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one + under the cursor) · `r` refresh · `/` filter — deletion asks to confirm first +- Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept +- Sending to a PIN-protected peer prompts for the PIN and retries +- `q` quit + +### Debugging + +Set `OMARCHY_SEND_LOG=/path/to/log` to record discovery/transfer events to a file. + +## Notes on iOS + +iOS LocalSend decides whether received media lands in the Photo Library based on +its own in-app settings; its post-receive "open file" prompt can fail to open +files from the app cache. Both behaviours occur identically with the official +desktop client and are not controlled by the sender. + +## License + +MIT — see [LICENSE](LICENSE). Omarchy-Send is an independent implementation of +the published [LocalSend protocol](https://github.com/localsend/protocol); it is +not affiliated with the LocalSend project. The terminal UI is built on the +[Charm](https://github.com/charmbracelet) libraries (also MIT). diff --git a/cmd/omarchy-send/main.go b/cmd/omarchy-send/main.go new file mode 100644 index 0000000..e49abab --- /dev/null +++ b/cmd/omarchy-send/main.go @@ -0,0 +1,124 @@ +// Command omarchy-send is a LocalSend-compatible file-transfer client with a terminal +// UI, designed to run headless over SSH on Arch/Omarchy servers. +package main + +import ( + "context" + "crypto/tls" + "flag" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "omarchy-send/internal/app" + "omarchy-send/internal/client" + "omarchy-send/internal/config" + "omarchy-send/internal/discovery" + "omarchy-send/internal/server" + "omarchy-send/internal/tui" +) + +// controller adapts the discovery + sender + server services to tui.Controller. +type controller struct { + disc *discovery.Discoverer + sender *client.Sender + srv *server.Server +} + +func (c controller) Announce() { c.disc.Announce() } +func (c controller) Send(p discovery.Peer, paths []string, pin string) { c.sender.Send(p, paths, pin) } +func (c controller) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) } +func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) } +func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) } + +// SetAlias updates the alias across all services and re-announces it. +func (c controller) SetAlias(alias string) { + c.disc.SetAlias(alias) + c.srv.SetAlias(alias) + c.sender.SetAlias(alias) + c.disc.Announce() +} + +func main() { + var ( + aliasFlag = flag.String("alias", "", "device alias (overrides config for this run)") + portFlag = flag.Int("port", 0, "listen port (overrides config for this run)") + dirFlag = flag.String("dir", "", "receive directory (overrides config for this run)") + pinFlag = flag.String("pin", "", "require this PIN from senders (overrides config)") + autoFlag = flag.Bool("auto-accept", false, "auto-accept incoming transfers (no prompt)") + noIcons = flag.Bool("no-icons", false, "hide Nerd Font device icons (for non-Nerd-Font terminals)") + ) + flag.Parse() + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "config: %v\n", err) + os.Exit(1) + } + if *aliasFlag != "" { + cfg.Alias = *aliasFlag + cfg.DeviceModel = *aliasFlag + } + if *portFlag != 0 { + cfg.Port = *portFlag + } + if *dirFlag != "" { + cfg.ReceiveDir = *dirFlag + } + if *pinFlag != "" { + cfg.PIN = *pinFlag + } + if *autoFlag { + cfg.AutoAccept = true + } + if *noIcons { + cfg.NoIcons = true + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + disc := discovery.New(cfg.DeviceInfo()) + + var cert *tls.Certificate + if cfg.Protocol == "https" { + c, err := cfg.TLSCertificate() + if err != nil { + fmt.Fprintf(os.Stderr, "tls: %v\n", err) + os.Exit(1) + } + cert = &c + } + + srv := server.New(server.Options{ + Info: cfg.DeviceInfo(), + OnPeer: disc.NotePeer, + Cert: cert, + ReceiveDir: cfg.ReceiveDir, + AutoAccept: cfg.AutoAccept, + PIN: cfg.PIN, + }) + if err := srv.Start(ctx); err != nil { + fmt.Fprintf(os.Stderr, "server: %v\n", err) + os.Exit(1) + } + if err := disc.Run(ctx); err != nil { + fmt.Fprintf(os.Stderr, "discovery: %v\n", err) + os.Exit(1) + } + + sender := client.New(cfg.DeviceInfo()) + ctrl := controller{disc: disc, sender: sender, srv: srv} + + p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen()) + app.BridgeDiscovery(ctx, disc.Events(), p.Send) + app.BridgeServer(ctx, srv.Accepts(), srv.Transfers(), p.Send) + app.BridgeTransfers(ctx, sender.Events(), p.Send) + disc.Announce() // announce immediately so we appear without waiting a tick + + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "tui: %v\n", err) + os.Exit(1) + } +} diff --git a/dist/omarchy-send.svg b/dist/omarchy-send.svg new file mode 100644 index 0000000..387f547 --- /dev/null +++ b/dist/omarchy-send.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e844e70 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module omarchy-send + +go 1.26.1 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..79e2c83 --- /dev/null +++ b/go.sum @@ -0,0 +1,64 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b9952a8 --- /dev/null +++ b/install.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# install.sh — install Omarchy-Send. +# +# Quick install (nothing to clone — the Once way): +# +# curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install.sh | bash +# +# When run from a git clone it builds from source instead (if Go is present), +# otherwise it downloads the latest released binary for your architecture. +# +# ./install.sh +# +# Environment overrides: +# BIN_DIR=/usr/local/bin install location (default ~/.local/bin) +# OMARCHY_SEND_VERSION=v0.1.0 pin a release (default: latest) +# +# Behaviour: +# - Headless system: installs the plain `omarchy-send` TUI binary. +# - Omarchy desktop: additionally adds a Walker entry that launches it as a +# floating TUI (via the stock TUI.float app-id), like the Wi-Fi TUI. + +set -euo pipefail + +REPO="28allday/omarchy-send" +BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" +APP_DIR="$HOME/.local/share/applications" +BIN="$BIN_DIR/omarchy-send" +VERSION="${OMARCHY_SEND_VERSION:-latest}" + +mkdir -p "$BIN_DIR" + +# If the script lives next to the source tree, we're in a clone. +SCRIPT_DIR="" +if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fi + +# ---- obtain the binary --------------------------------------------------- +if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.mod" ] && command -v go >/dev/null 2>&1; then + echo "==> Building omarchy-send from source..." + (cd "$SCRIPT_DIR" && go build -o "$BIN" ./cmd/omarchy-send) +else + # Download the released binary for this OS/arch (curl-style install). + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + if [ "$os" != "linux" ]; then + echo "ERROR: omarchy-send ships Linux binaries only (detected: $os)." >&2 + echo " On other systems, clone the repo and build with Go." >&2 + exit 1 + fi + case "$(uname -m)" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) echo "ERROR: unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + asset="omarchy-send-${os}-${arch}" + if [ "$VERSION" = "latest" ]; then + url="https://github.com/$REPO/releases/latest/download/$asset" + else + url="https://github.com/$REPO/releases/download/$VERSION/$asset" + fi + + echo "==> Downloading $asset ($VERSION)..." + tmp="$(mktemp)" + trap 'rm -f "$tmp"' EXIT + if command -v curl >/dev/null 2>&1; then + curl -fSL --proto '=https' --tlsv1.2 -o "$tmp" "$url" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$tmp" "$url" + else + echo "ERROR: need curl or wget to download the binary." >&2 + exit 1 + fi + install -m 755 "$tmp" "$BIN" +fi +echo " Installed: $BIN" + +# ---- Omarchy desktop integration ----------------------------------------- +# Only on Omarchy: add a Walker entry that opens the TUI in a floating window. +# The TUI.float app-id is matched by Omarchy's stock floating-window rule, so +# no Hyprland configuration is required. +if command -v omarchy-launch-tui >/dev/null 2>&1 || [ -d "$HOME/.local/share/omarchy" ]; then + mkdir -p "$APP_DIR" + + # Install the bundled icon into the user's hicolor theme. The SVG is embedded + # so the curl-piped install has nothing extra to fetch. + icon_dir="$HOME/.local/share/icons/hicolor/scalable/apps" + mkdir -p "$icon_dir" + cat > "$icon_dir/omarchy-send.svg" <<'SVG' + + + + + + +SVG + gtk-update-icon-cache -q -t -f "$HOME/.local/share/icons/hicolor" 2>/dev/null || true + + cat > "$APP_DIR/omarchy-send.desktop" < Omarchy detected — added floating Walker entry (with icon)." + echo " Launch it from Walker by searching 'Omarchy-Send'." +else + echo "==> Headless system — installed as a plain TUI." +fi + +echo +case ":$PATH:" in + *":$BIN_DIR:"*) : ;; + *) echo "Note: $BIN_DIR is not on your PATH. Add it, or run $BIN directly." ;; +esac +echo "Done. Run: omarchy-send" diff --git a/internal/app/events.go b/internal/app/events.go new file mode 100644 index 0000000..22d35da --- /dev/null +++ b/internal/app/events.go @@ -0,0 +1,79 @@ +// Package app bridges the Tea-agnostic domain layer (discovery, server, client) +// into Bubble Tea messages. Domain components emit on Go channels; a bridge +// goroutine forwards them via tea.Program.Send. +package app + +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" + + "omarchy-send/internal/discovery" + "omarchy-send/internal/server" + "omarchy-send/internal/transfer" +) + +// PeerFoundMsg is delivered when a peer is discovered or its address changes. +type PeerFoundMsg struct{ Peer discovery.Peer } + +// PeerLostMsg is delivered when a peer ages out (post-M1). +type PeerLostMsg struct{ Fingerprint string } + +// IncomingMsg is delivered when a peer asks to send us files. The TUI shows an +// accept prompt and answers via the carried Reply channel. +type IncomingMsg struct{ Req server.AcceptRequest } + +// TransferMsg reports progress/lifecycle of a transfer (either direction). +type TransferMsg struct{ Ev transfer.Event } + +// BridgeServer forwards the server's accept requests and transfer events to the +// Tea program until ctx is cancelled. +func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, transfers <-chan transfer.Event, send func(tea.Msg)) { + go func() { + for { + select { + case <-ctx.Done(): + return + case req := <-accepts: + send(IncomingMsg{Req: req}) + case ev := <-transfers: + send(TransferMsg{Ev: ev}) + } + } + }() +} + +// BridgeTransfers forwards a transfer event channel (e.g. the sender's) to the +// Tea program until ctx is cancelled. +func BridgeTransfers(ctx context.Context, transfers <-chan transfer.Event, send func(tea.Msg)) { + go func() { + for { + select { + case <-ctx.Done(): + return + case ev := <-transfers: + send(TransferMsg{Ev: ev}) + } + } + }() +} + +// BridgeDiscovery forwards discovery events to the Tea program until ctx is +// cancelled. send is typically tea.Program.Send. +func BridgeDiscovery(ctx context.Context, events <-chan discovery.Event, send func(tea.Msg)) { + go func() { + for { + select { + case <-ctx.Done(): + return + case ev := <-events: + switch ev.Kind { + case discovery.PeerFound: + send(PeerFoundMsg{Peer: ev.Peer}) + case discovery.PeerLost: + send(PeerLostMsg{Fingerprint: ev.Peer.Info.Fingerprint}) + } + } + } + }() +} diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..88204ec --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,238 @@ +// Package client implements the sender side of the LocalSend upload flow: +// prepare-upload to a peer, then stream each file to /upload, emitting progress. +package client + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "mime" + "net" + "net/http" + neturl "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "omarchy-send/internal/dbg" + "omarchy-send/internal/discovery" + "omarchy-send/internal/protocol" + "omarchy-send/internal/transfer" +) + +// Sender uploads files to peers. Events are delivered on Events(). +type Sender struct { + mu sync.Mutex + self protocol.DeviceInfo + http *http.Client + events chan transfer.Event +} + +// New returns a Sender advertising self. TLS chain validation is disabled (we +// rely on LocalSend's fingerprint model, like the discovery client). +func New(self protocol.DeviceInfo) *Sender { + return &Sender{ + self: self, + http: &http.Client{ + Timeout: 0, // large files: no overall timeout + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, + }, + events: make(chan transfer.Event, 256), + } +} + +// Events returns the outgoing-transfer event channel. +func (s *Sender) Events() <-chan transfer.Event { return s.events } + +// SetAlias updates the alias we present to peers when sending, at runtime. +func (s *Sender) SetAlias(alias string) { + s.mu.Lock() + s.self.Alias = alias + s.self.DeviceModel = alias + s.mu.Unlock() +} + +func (s *Sender) selfCopy() protocol.DeviceInfo { + s.mu.Lock() + defer s.mu.Unlock() + return s.self +} + +// Send uploads the given file paths to peer in a background goroutine. pin may +// be empty; supply it when the peer requires one. +func (s *Sender) Send(peer discovery.Peer, paths []string, pin string) { + go s.send(peer, paths, pin) +} + +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 + } + id := randID() + files[id] = protocol.FileMetadata{ + ID: id, + FileName: filepath.Base(p), + Size: fi.Size(), + FileType: mimeType(p), + } + pathByID[id] = p + } + if len(files) == 0 { + return + } + + if meta, err := json.Marshal(files); err == nil { + dbg.Logf("SEND prepare-upload to %s: files=%s", peer.IP, string(meta)) + } + base := s.url(peer) + prepResp, err := s.prepareUpload(base, files, pin) + if err != nil { + dbg.Logf("send prepare-upload to %s failed: %v", peer.IP, err) + if errors.Is(err, transfer.ErrPinRequired) { + // One signal is enough for the TUI to prompt + retry. + s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, Err: transfer.ErrPinRequired}) + return + } + for id, m := range files { + s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, ID: id, FileName: m.FileName, Err: err}) + } + return + } + + for id, token := range prepResp.Files { + meta := files[id] + key := prepResp.SessionID + ":" + id + if err := s.uploadFile(base, prepResp.SessionID, id, token, key, pathByID[id], meta); err != nil { + dbg.Logf("send upload %q failed: %v", meta.FileName, err) + s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, ID: key, FileName: meta.FileName, Err: err}) + continue + } + s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.FileDone, ID: key, FileName: meta.FileName, Received: meta.Size, Total: meta.Size}) + } +} + +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 + if pin != "" { + url += "?pin=" + neturl.QueryEscape(pin) + } + resp, err := s.http.Post(url, "application/json", bytes.NewReader(reqBody)) + if err != nil { + return protocol.PrepareUploadResponse{}, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + return protocol.PrepareUploadResponse{}, transfer.ErrPinRequired + } + if resp.StatusCode != http.StatusOK { + return protocol.PrepareUploadResponse{}, fmt.Errorf("prepare-upload status %d", resp.StatusCode) + } + var pr protocol.PrepareUploadResponse + if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { + return protocol.PrepareUploadResponse{}, err + } + return pr, nil +} + +func (s *Sender) uploadFile(base, sessionID, fileID, token, key, path string, meta protocol.FileMetadata) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Start, ID: key, FileName: meta.FileName, Total: meta.Size}) + + pr := &progressReader{ + r: f, + total: meta.Size, + emit: func(sent int64) { + s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Progress, ID: key, FileName: meta.FileName, Received: sent, Total: meta.Size}) + }, + } + + url := fmt.Sprintf("%s%s?sessionId=%s&fileId=%s&token=%s", base, protocol.PathUpload, sessionID, fileID, token) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, pr) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/octet-stream") + req.ContentLength = meta.Size + + resp, err := s.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("upload status %d", resp.StatusCode) + } + return nil +} + +func (s *Sender) url(peer discovery.Peer) string { + scheme := "https" + if peer.Info.Protocol == "http" { + scheme = "http" + } + port := peer.Info.Port + if port == 0 { + port = protocol.DefaultPort + } + return fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(peer.IP, strconv.Itoa(port))) +} + +func (s *Sender) emit(ev transfer.Event) { + select { + case s.events <- ev: + default: + } +} + +// builtinMIME maps common extensions to MIME types so a headless server +// without /etc/mime.types still labels photos/videos correctly. (Go's built-in +// table omits .jpg and mislabels .heic as image/heif.) +var builtinMIME = map[string]string{ + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", + ".tiff": "image/tiff", ".tif": "image/tiff", ".heic": "image/heic", + ".heif": "image/heif", ".dng": "image/x-adobe-dng", ".svg": "image/svg+xml", + ".mp4": "video/mp4", ".mov": "video/quicktime", ".m4v": "video/x-m4v", + ".mkv": "video/x-matroska", ".webm": "video/webm", ".avi": "video/x-msvideo", + ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".wav": "audio/wav", + ".flac": "audio/flac", ".ogg": "audio/ogg", ".opus": "audio/opus", + ".pdf": "application/pdf", ".zip": "application/zip", ".txt": "text/plain", +} + +// mimeType resolves a file's MIME type, preferring our built-in table, then the +// system table, then a safe default. +func mimeType(path string) string { + ext := strings.ToLower(filepath.Ext(path)) + if t, ok := builtinMIME[ext]; ok { + return t + } + if t := mime.TypeByExtension(ext); t != "" { + return t + } + return "application/octet-stream" +} + +func randID() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..bfa4d12 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,73 @@ +package client + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "omarchy-send/internal/discovery" + "omarchy-send/internal/protocol" + "omarchy-send/internal/server" + "omarchy-send/internal/transfer" +) + +// TestSendToReceiver drives our sender against our receiver over loopback HTTP +// and asserts the file arrives intact — the M3 end-to-end guarantee. +func TestSendToReceiver(t *testing.T) { + recvDir := t.TempDir() + recvInfo := protocol.DeviceInfo{ + Alias: "recv", Version: protocol.ProtocolVersion, Port: 53995, 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) + + // Source file to send. + srcDir := t.TempDir() + srcPath := filepath.Join(srcDir, "photo.jpg") + content := bytes.Repeat([]byte("localsend-payload-"), 5000) // ~90KB + if err := os.WriteFile(srcPath, content, 0o644); err != nil { + t.Fatalf("write src: %v", err) + } + + 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{srcPath}, "") + + // Wait for the outgoing FileDone (or fail on error/timeout). + deadline := time.After(5 * time.Second) + for { + select { + case ev := <-sender.Events(): + if ev.Kind == transfer.Error { + t.Fatalf("send error: %v", ev.Err) + } + if ev.Kind == transfer.FileDone { + goto verify + } + case <-deadline: + t.Fatal("timed out waiting for send to complete") + } + } + +verify: + got, err := os.ReadFile(filepath.Join(recvDir, "photo.jpg")) + if err != nil { + t.Fatalf("read received: %v", err) + } + if !bytes.Equal(got, content) { + t.Fatalf("content mismatch: %d vs %d bytes", len(got), len(content)) + } +} diff --git a/internal/client/progress.go b/internal/client/progress.go new file mode 100644 index 0000000..29994fc --- /dev/null +++ b/internal/client/progress.go @@ -0,0 +1,28 @@ +package client + +import ( + "io" + "time" +) + +// progressReader wraps a file reader, emitting throttled byte-count callbacks as +// the body is streamed to the peer. +type progressReader struct { + r io.Reader + total int64 + read int64 + emit func(sent int64) + lastEmit time.Time +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + p.read += int64(n) + if err == io.EOF || time.Since(p.lastEmit) > 100*time.Millisecond { + p.lastEmit = time.Now() + if p.emit != nil { + p.emit(p.read) + } + } + return n, err +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f5f40d5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,163 @@ +// Package config loads and persists user settings under the XDG config dir. +package config + +import ( + "crypto/tls" + "encoding/json" + "os" + "path/filepath" + + "omarchy-send/internal/protocol" + "omarchy-send/internal/security" +) + +// Config is the persisted user configuration. +type Config struct { + Alias string `json:"alias"` + Fingerprint string `json:"fingerprint"` + Port int `json:"port"` + ReceiveDir string `json:"receiveDir"` + DeviceModel string `json:"deviceModel"` + DeviceType string `json:"deviceType"` + Protocol string `json:"protocol"` + AutoAccept bool `json:"autoAccept"` + PIN string `json:"pin"` // if set, senders must supply it + NoIcons bool `json:"noIcons"` // hide Nerd Font device icons (non-NF terminals) + + // TLS identity for encrypted (HTTPS) mode, generated once and persisted. + CertPEM string `json:"certPem"` + KeyPEM string `json:"keyPem"` + + // path is where this config was loaded from / will be saved to. + path string `json:"-"` +} + +// Dir returns the config directory, e.g. ~/.config/omarchy-send. +func Dir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(base, "omarchy-send"), nil +} + +// defaults returns a Config populated with sensible defaults for this host. +func defaults() Config { + host, err := os.Hostname() + if err != nil || host == "" { + host = "arch" + } + home, _ := os.UserHomeDir() + return Config{ + Alias: host, + Port: protocol.DefaultPort, + ReceiveDir: filepath.Join(home, "Omarchy-Send"), + DeviceModel: host, + DeviceType: string(protocol.DeviceServer), + Protocol: "https", + AutoAccept: false, + } +} + +// Load reads the config from dir/config.json, filling defaults for missing +// fields. If the file does not exist it is created. A fingerprint is generated +// and persisted on first run. +func Load() (Config, error) { + dir, err := Dir() + if err != nil { + return Config{}, err + } + path := filepath.Join(dir, "config.json") + + cfg := defaults() + cfg.path = path + + data, err := os.ReadFile(path) + switch { + case err == nil: + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, err + } + cfg.path = path + case !os.IsNotExist(err): + return Config{}, err + } + + // Backfill anything still empty after unmarshalling an older/partial file. + d := defaults() + if cfg.Alias == "" { + cfg.Alias = d.Alias + } + if cfg.Port == 0 { + cfg.Port = d.Port + } + if cfg.ReceiveDir == "" { + cfg.ReceiveDir = d.ReceiveDir + } + if cfg.DeviceType == "" { + cfg.DeviceType = d.DeviceType + } + if cfg.Protocol == "" { + cfg.Protocol = d.Protocol + } + + // Generate the TLS identity once. The fingerprint is derived from the + // certificate (uppercase-hex SHA-256 of its DER), so it is regenerated + // alongside the cert and persisted. + if cfg.CertPEM == "" || cfg.KeyPEM == "" { + id, err := security.Generate() + if err != nil { + return Config{}, err + } + cfg.CertPEM = id.CertPEM + cfg.KeyPEM = id.KeyPEM + cfg.Fingerprint = id.Fingerprint + } + + if err := cfg.Save(); err != nil { + return Config{}, err + } + return cfg, nil +} + +// Save atomically writes the config (temp file + rename). +func (c Config) Save() error { + if c.path == "" { + dir, err := Dir() + if err != nil { + return err + } + c.path = filepath.Join(dir, "config.json") + } + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + tmp := c.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, c.path) +} + +// DeviceInfo builds the protocol announcement payload from this config. The +// caller sets Announce as appropriate. +func (c Config) DeviceInfo() protocol.DeviceInfo { + return protocol.DeviceInfo{ + Alias: c.Alias, + Version: protocol.ProtocolVersion, + DeviceModel: c.DeviceModel, + DeviceType: protocol.DeviceType(c.DeviceType), + Fingerprint: c.Fingerprint, + Port: c.Port, + Protocol: c.Protocol, + } +} + +// TLSCertificate returns the parsed TLS keypair for serving HTTPS. +func (c Config) TLSCertificate() (tls.Certificate, error) { + return tls.X509KeyPair([]byte(c.CertPEM), []byte(c.KeyPEM)) +} diff --git a/internal/dbg/dbg.go b/internal/dbg/dbg.go new file mode 100644 index 0000000..5110a5c --- /dev/null +++ b/internal/dbg/dbg.go @@ -0,0 +1,39 @@ +// Package dbg provides opt-in debug logging to the file named by $OMARCHY_SEND_LOG. +// When the variable is unset, logging is a no-op. It is safe for concurrent use. +package dbg + +import ( + "fmt" + "os" + "sync" + "time" +) + +var ( + once sync.Once + mu sync.Mutex + f *os.File +) + +func setup() { + path := os.Getenv("OMARCHY_SEND_LOG") + if path == "" { + return + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return + } + f = file +} + +// Logf appends a timestamped line to the debug log if $OMARCHY_SEND_LOG is set. +func Logf(format string, args ...any) { + once.Do(setup) + if f == nil { + return + } + mu.Lock() + defer mu.Unlock() + fmt.Fprintf(f, "%s "+format+"\n", append([]any{time.Now().Format("15:04:05.000")}, args...)...) +} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..0e74b86 --- /dev/null +++ b/internal/discovery/discovery.go @@ -0,0 +1,286 @@ +// Package discovery implements LocalSend multicast discovery: it announces this +// device on 224.0.0.167:53317 and listens for other devices, emitting peer +// events on a channel. It is Tea-agnostic; the app layer bridges its events +// into Bubble Tea messages. +package discovery + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "strconv" + "sync" + "time" + + "omarchy-send/internal/dbg" + "omarchy-send/internal/protocol" +) + +// EventKind distinguishes peer lifecycle events. +type EventKind int + +const ( + PeerFound EventKind = iota + PeerLost +) + +// Peer is a discovered device plus the address we reached it at. +type Peer struct { + Info protocol.DeviceInfo + IP string + LastSeen time.Time +} + +// Event is emitted on the Discoverer's channel. +type Event struct { + Kind EventKind + Peer Peer +} + +const ( + // announceInterval is how often we re-announce ourselves. + announceInterval = 5 * time.Second + // peerTTL is how long a peer survives without being seen before eviction. + peerTTL = 20 * time.Second + // reapInterval is how often we check for stale peers. + reapInterval = 5 * time.Second +) + +// Discoverer announces this device and tracks discovered peers. +type Discoverer struct { + self protocol.DeviceInfo + events chan Event + client *http.Client + gaddr *net.UDPAddr + + mu sync.Mutex + conn *net.UDPConn // multicast listener (group-bound) + sendConn *net.UDPConn // dedicated sender (dialed to the group) + peers map[string]Peer // keyed by fingerprint +} + +// New returns a Discoverer that advertises self. +func New(self protocol.DeviceInfo) *Discoverer { + return &Discoverer{ + self: self, + events: make(chan Event, 64), + // Peers use self-signed certs; we don't validate the chain (LocalSend + // pins the announced fingerprint instead). + client: &http.Client{ + Timeout: 3 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + }, + peers: make(map[string]Peer), + } +} + +// Events returns the channel on which peer events are delivered. +func (d *Discoverer) Events() <-chan Event { return d.events } + +// SetAlias updates the advertised alias (and device model) at runtime. Call +// Announce afterwards to push it out immediately. +func (d *Discoverer) SetAlias(alias string) { + d.mu.Lock() + d.self.Alias = alias + d.self.DeviceModel = alias + d.mu.Unlock() +} + +// selfCopy returns the current self info under lock. +func (d *Discoverer) selfCopy() protocol.DeviceInfo { + d.mu.Lock() + defer d.mu.Unlock() + return d.self +} + +// Run joins the multicast group and starts the listener and announcer +// goroutines. It returns once the socket is bound. +func (d *Discoverer) Run(ctx context.Context) error { + gaddr, err := net.ResolveUDPAddr("udp4", + net.JoinHostPort(protocol.MulticastAddr, strconv.Itoa(protocol.MulticastPort))) + if err != nil { + return err + } + // ListenMulticastUDP sets SO_REUSEADDR, so this coexists with the real + // LocalSend app (and a second instance) on the same host. + conn, err := net.ListenMulticastUDP("udp4", nil, gaddr) + if err != nil { + return err + } + _ = conn.SetReadBuffer(1 << 20) + + // A packet sent via the group-bound listen socket does not loop back to + // other local members, so announcements go out on a dedicated dialed socket + // whose source interface the kernel picks (which does loop back correctly). + sendConn, err := net.DialUDP("udp4", nil, gaddr) + if err != nil { + _ = conn.Close() + return err + } + + d.mu.Lock() + d.conn = conn + d.sendConn = sendConn + d.gaddr = gaddr + d.mu.Unlock() + + go d.listen(ctx, conn) + go d.announceLoop(ctx) + go d.reapLoop(ctx) + + go func() { + <-ctx.Done() + _ = conn.Close() + _ = sendConn.Close() + }() + return nil +} + +// Announce multicasts our presence with announce:true, soliciting replies. +func (d *Discoverer) Announce() { + d.mu.Lock() + send := d.sendConn + self := d.self + d.mu.Unlock() + if send == nil { + return + } + payload, err := json.Marshal(self.WithAnnounce(true)) + if err != nil { + return + } + _, _ = send.Write(payload) +} + +func (d *Discoverer) announceLoop(ctx context.Context) { + t := time.NewTicker(announceInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + d.Announce() + } + } +} + +// reapLoop evicts peers not seen within peerTTL, emitting PeerLost for each. +func (d *Discoverer) reapLoop(ctx context.Context) { + t := time.NewTicker(reapInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + d.reapOnce(time.Now()) + } + } +} + +// reapOnce evicts peers whose LastSeen is older than peerTTL relative to now, +// emitting PeerLost for each. Returns the number evicted. +func (d *Discoverer) reapOnce(now time.Time) int { + var lost []Peer + d.mu.Lock() + for fp, p := range d.peers { + if now.Sub(p.LastSeen) > peerTTL { + lost = append(lost, p) + delete(d.peers, fp) + } + } + d.mu.Unlock() + for _, p := range lost { + dbg.Logf("peer evicted (stale >%.0fs): alias=%q ip=%s", peerTTL.Seconds(), p.Info.Alias, p.IP) + d.emit(Event{Kind: PeerLost, Peer: p}) + } + return len(lost) +} + +func (d *Discoverer) listen(ctx context.Context, conn *net.UDPConn) { + buf := make([]byte, 64*1024) + for { + n, src, err := conn.ReadFromUDP(buf) + if err != nil { + if ctx.Err() != nil { + return + } + continue + } + var info protocol.DeviceInfo + if err := json.Unmarshal(buf[:n], &info); err != nil { + continue + } + if info.Fingerprint == "" || info.Fingerprint == d.self.Fingerprint { + continue // malformed or our own announcement + } + ip := src.IP.String() + dbg.Logf("multicast from %s: alias=%q proto=%s port=%d announce=%v", + ip, info.Alias, info.Protocol, info.Port, info.Announce) + + // A probe (announce:true) expects a reply with our info (announce:false), + // sent to the peer's /register so it learns about us reliably. + if info.Announce != nil && *info.Announce { + go d.reply(ip, info.Port, info.Protocol) + } + d.NotePeer(info, ip) + } +} + +// reply POSTs our device info to a peer's /register endpoint, using the scheme +// the peer advertised (https for encrypted peers, http otherwise). +func (d *Discoverer) reply(ip string, port int, proto string) { + if port == 0 { + port = protocol.DefaultPort + } + scheme := "https" + if proto == "http" { + scheme = "http" + } + body, err := json.Marshal(d.selfCopy().WithAnnounce(false)) + if err != nil { + return + } + url := fmt.Sprintf("%s://%s/api/localsend/v2/register", scheme, net.JoinHostPort(ip, strconv.Itoa(port))) + resp, err := d.client.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + dbg.Logf("reply POST %s FAILED: %v", url, err) + return + } + dbg.Logf("reply POST %s -> %s", url, resp.Status) + _ = resp.Body.Close() +} + +// NotePeer records a peer (from multicast or from an inbound /register) and +// emits PeerFound on first sight or when its address changes. Safe for +// concurrent use; ignores our own fingerprint. +func (d *Discoverer) NotePeer(info protocol.DeviceInfo, ip string) { + if info.Fingerprint == "" || info.Fingerprint == d.self.Fingerprint { + return + } + peer := Peer{Info: info, IP: ip, LastSeen: time.Now()} + + d.mu.Lock() + prev, existed := d.peers[info.Fingerprint] + changed := !existed || prev.IP != ip || prev.Info.Alias != info.Alias + d.peers[info.Fingerprint] = peer + d.mu.Unlock() + + if changed { + d.emit(Event{Kind: PeerFound, Peer: peer}) + } +} + +func (d *Discoverer) emit(ev Event) { + select { + case d.events <- ev: + default: // drop rather than block the network goroutine + } +} diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go new file mode 100644 index 0000000..f9ec4cb --- /dev/null +++ b/internal/discovery/discovery_test.go @@ -0,0 +1,102 @@ +package discovery + +import ( + "context" + "testing" + "time" + + "omarchy-send/internal/protocol" + "omarchy-send/internal/server" +) + +func mkInfo(alias, fp string, port int) protocol.DeviceInfo { + return protocol.DeviceInfo{ + Alias: alias, + Version: protocol.ProtocolVersion, + DeviceType: protocol.DeviceServer, + Fingerprint: fp, + Port: port, + Protocol: "http", + } +} + +// TestMutualDiscovery runs two full nodes (discovery + HTTP server) on the same +// host with different HTTP ports and asserts they discover each other over +// multicast within a short window. Requires a multicast-capable loopback path. +func TestMutualDiscovery(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + infoA := mkInfo("node-a", "aaaa1111", 54101) + infoB := mkInfo("node-b", "bbbb2222", 54102) + + discA := New(infoA) + discB := New(infoB) + + srvA := server.New(server.Options{Info: infoA, OnPeer: discA.NotePeer}) + srvB := server.New(server.Options{Info: infoB, OnPeer: discB.NotePeer}) + if err := srvA.Start(ctx); err != nil { + t.Fatalf("srvA: %v", err) + } + if err := srvB.Start(ctx); err != nil { + t.Fatalf("srvB: %v", err) + } + if err := discA.Run(ctx); err != nil { + t.Fatalf("discA: %v", err) + } + if err := discB.Run(ctx); err != nil { + t.Fatalf("discB: %v", err) + } + + discA.Announce() + discB.Announce() + + if !waitForPeer(t, discA.Events(), "bbbb2222") { + t.Fatal("node-a did not discover node-b") + } + if !waitForPeer(t, discB.Events(), "aaaa1111") { + t.Fatal("node-b did not discover node-a") + } +} + +// TestReapEvictsStalePeer checks that a peer older than peerTTL is evicted and +// a fresh one is kept. +func TestReapEvictsStalePeer(t *testing.T) { + d := New(mkInfo("self", "selffp", 1)) + now := time.Now() + d.peers["stale"] = Peer{Info: mkInfo("old", "stale", 2), IP: "1.1.1.1", LastSeen: now.Add(-peerTTL - time.Second)} + d.peers["fresh"] = Peer{Info: mkInfo("new", "fresh", 3), IP: "2.2.2.2", LastSeen: now} + + if n := d.reapOnce(now); n != 1 { + t.Fatalf("evicted %d, want 1", n) + } + if _, ok := d.peers["stale"]; ok { + t.Fatal("stale peer not evicted") + } + if _, ok := d.peers["fresh"]; !ok { + t.Fatal("fresh peer wrongly evicted") + } + select { + case ev := <-d.Events(): + if ev.Kind != PeerLost || ev.Peer.Info.Fingerprint != "stale" { + t.Fatalf("unexpected event: %+v", ev) + } + default: + t.Fatal("expected a PeerLost event") + } +} + +func waitForPeer(t *testing.T, events <-chan Event, fingerprint string) bool { + t.Helper() + deadline := time.After(8 * time.Second) + for { + select { + case ev := <-events: + if ev.Kind == PeerFound && ev.Peer.Info.Fingerprint == fingerprint { + return true + } + case <-deadline: + return false + } + } +} diff --git a/internal/protocol/consts.go b/internal/protocol/consts.go new file mode 100644 index 0000000..48d60a3 --- /dev/null +++ b/internal/protocol/consts.go @@ -0,0 +1,20 @@ +// Package protocol holds the LocalSend v2.1 wire types and constants. It +// performs no I/O so it can be unit-tested against captured payloads. +package protocol + +// Network defaults from the LocalSend v2.1 spec. +const ( + MulticastAddr = "224.0.0.167" + MulticastPort = 53317 + DefaultPort = 53317 + ProtocolVersion = "2.1" +) + +// HTTP API paths, all served under the device's port. +const ( + PathRegister = "/api/localsend/v2/register" + PathInfo = "/api/localsend/v2/info" + PathPrepareUpload = "/api/localsend/v2/prepare-upload" + PathUpload = "/api/localsend/v2/upload" + PathCancel = "/api/localsend/v2/cancel" +) diff --git a/internal/protocol/device.go b/internal/protocol/device.go new file mode 100644 index 0000000..2225988 --- /dev/null +++ b/internal/protocol/device.go @@ -0,0 +1,37 @@ +package protocol + +// DeviceType is the kind of device, as shown (with an icon) in peer lists. +type DeviceType string + +const ( + DeviceMobile DeviceType = "mobile" + DeviceDesktop DeviceType = "desktop" + DeviceWeb DeviceType = "web" + DeviceHeadless DeviceType = "headless" + DeviceServer DeviceType = "server" +) + +// DeviceInfo is the announcement / handshake payload. It is sent over multicast +// (with Announce set), returned by GET /info, and exchanged on POST /register. +// +// Announce is a pointer so we can distinguish three states on the wire: +// - nil → field omitted (e.g. /info responses) +// - true → a discovery probe expecting replies +// - false → a reply to someone else's probe +type DeviceInfo struct { + Alias string `json:"alias"` + Version string `json:"version"` + DeviceModel string `json:"deviceModel,omitempty"` + DeviceType DeviceType `json:"deviceType,omitempty"` + Fingerprint string `json:"fingerprint"` + Port int `json:"port"` + Protocol string `json:"protocol"` + Download bool `json:"download,omitempty"` + Announce *bool `json:"announce,omitempty"` +} + +// WithAnnounce returns a copy of d with the Announce flag set to v. +func (d DeviceInfo) WithAnnounce(v bool) DeviceInfo { + d.Announce = &v + return d +} diff --git a/internal/protocol/files.go b/internal/protocol/files.go new file mode 100644 index 0000000..81707f8 --- /dev/null +++ b/internal/protocol/files.go @@ -0,0 +1,34 @@ +package protocol + +// FileMetadata describes one file offered in a prepare-upload request. SHA256 +// and Preview are optional; the spec permits omitting the hash to avoid +// pre-hashing large files. +type FileMetadata struct { + ID string `json:"id"` + FileName string `json:"fileName"` + Size int64 `json:"size"` + FileType string `json:"fileType"` + SHA256 string `json:"sha256,omitempty"` + Preview string `json:"preview,omitempty"` + Metadata *FileTimestamps `json:"metadata,omitempty"` +} + +// FileTimestamps carries optional modified/accessed times (RFC 3339 strings). +type FileTimestamps struct { + Modified string `json:"modified,omitempty"` + Accessed string `json:"accessed,omitempty"` +} + +// PrepareUploadRequest is the body POSTed to /prepare-upload by the sender. +// Files is keyed by fileId. +type PrepareUploadRequest struct { + Info DeviceInfo `json:"info"` + Files map[string]FileMetadata `json:"files"` +} + +// PrepareUploadResponse is returned by the receiver: a session id plus a +// single-use token per fileId. +type PrepareUploadResponse struct { + SessionID string `json:"sessionId"` + Files map[string]string `json:"files"` +} diff --git a/internal/security/cert.go b/internal/security/cert.go new file mode 100644 index 0000000..22ffe77 --- /dev/null +++ b/internal/security/cert.go @@ -0,0 +1,68 @@ +// Package security generates and handles the self-signed TLS identity used for +// LocalSend's encrypted (HTTPS) mode. +// +// The LocalSend fingerprint is the SHA-256 of the certificate's DER bytes, +// encoded as uppercase hex (verified against the official client's stored +// certificateHash). Peers do not validate the certificate chain; they pin this +// fingerprint, which is advertised in the discovery announce. +package security + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "math/big" + "strings" + "time" +) + +// Identity is a self-signed certificate, its key, and its LocalSend fingerprint. +type Identity struct { + CertPEM string + KeyPEM string + Fingerprint string +} + +// Fingerprint returns the LocalSend fingerprint of a DER-encoded certificate: +// uppercase hex of its SHA-256. +func Fingerprint(der []byte) string { + sum := sha256.Sum256(der) + return strings.ToUpper(hex.EncodeToString(sum[:])) +} + +// Generate creates a fresh RSA-2048 self-signed certificate matching the shape +// the official LocalSend client uses (CN "LocalSend User", ~10-year validity). +func Generate() (Identity, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return Identity{}, err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return Identity{}, err + } + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "LocalSend User"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + return Identity{}, err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + return Identity{ + CertPEM: string(certPEM), + KeyPEM: string(keyPEM), + Fingerprint: Fingerprint(der), + }, nil +} diff --git a/internal/server/events.go b/internal/server/events.go new file mode 100644 index 0000000..73e36d0 --- /dev/null +++ b/internal/server/events.go @@ -0,0 +1,18 @@ +package server + +import "omarchy-send/internal/protocol" + +// AcceptDecision is the user's response to an incoming upload request. +type AcceptDecision struct { + Accept bool +} + +// AcceptRequest is raised when a peer asks to upload. The prepare-upload +// handler blocks on Reply until the TUI (or auto-accept) answers. +type AcceptRequest struct { + From protocol.DeviceInfo + IP string + Files map[string]protocol.FileMetadata + TotalSize int64 + Reply chan AcceptDecision +} diff --git a/internal/server/pin_test.go b/internal/server/pin_test.go new file mode 100644 index 0000000..c9f9330 --- /dev/null +++ b/internal/server/pin_test.go @@ -0,0 +1,56 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "omarchy-send/internal/protocol" +) + +// TestPrepareUploadPIN verifies the PIN gate: missing/wrong PIN -> 401, correct +// PIN -> 200 with a session. +func TestPrepareUploadPIN(t *testing.T) { + dir := t.TempDir() + info := protocol.DeviceInfo{Alias: "recv", Version: protocol.ProtocolVersion, Port: 53994, Protocol: "http"} + s := New(Options{Info: info, ReceiveDir: dir, AutoAccept: true, PIN: "2468"}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := s.Start(ctx); err != nil { + t.Fatalf("start: %v", err) + } + go func() { + for range s.Transfers() { + } + }() + time.Sleep(50 * time.Millisecond) + + base := "http://127.0.0.1:53994" + protocol.PathPrepareUpload + body, _ := json.Marshal(protocol.PrepareUploadRequest{ + Info: protocol.DeviceInfo{Alias: "snd", Fingerprint: "x", Version: "2.1"}, + Files: map[string]protocol.FileMetadata{"f1": {ID: "f1", FileName: "a.txt", Size: 1}}, + }) + + post := func(url string) int { + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + resp.Body.Close() + return resp.StatusCode + } + + if code := post(base); code != http.StatusUnauthorized { + t.Fatalf("no PIN: got %d, want 401", code) + } + if code := post(base + "?pin=0000"); code != http.StatusUnauthorized { + t.Fatalf("wrong PIN: got %d, want 401", code) + } + if code := post(base + "?pin=2468"); code != http.StatusOK { + t.Fatalf("correct PIN: got %d, want 200", code) + } +} diff --git a/internal/server/progress.go b/internal/server/progress.go new file mode 100644 index 0000000..9b1e5eb --- /dev/null +++ b/internal/server/progress.go @@ -0,0 +1,36 @@ +package server + +import ( + "context" + "io" + "time" +) + +// progressReader wraps an io.Reader, emitting throttled byte-count callbacks +// and aborting when its context is cancelled (so /cancel stops a live write). +type progressReader struct { + r io.Reader + total int64 + read int64 + ctx context.Context + emit func(received int64) + lastEmit time.Time +} + +func (p *progressReader) Read(b []byte) (int, error) { + select { + case <-p.ctx.Done(): + return 0, p.ctx.Err() + default: + } + n, err := p.r.Read(b) + p.read += int64(n) + // Throttle to ~10 emits/sec, but always emit on EOF so the bar reaches 100%. + if err == io.EOF || time.Since(p.lastEmit) > 100*time.Millisecond { + p.lastEmit = time.Now() + if p.emit != nil { + p.emit(p.read) + } + } + return n, err +} diff --git a/internal/server/receive_test.go b/internal/server/receive_test.go new file mode 100644 index 0000000..2f7d42d --- /dev/null +++ b/internal/server/receive_test.go @@ -0,0 +1,87 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "omarchy-send/internal/protocol" +) + +// TestReceiveFlow drives the full receiver path: prepare-upload (auto-accepted) +// then upload, and asserts the bytes land intact in the receive dir. +func TestReceiveFlow(t *testing.T) { + dir := t.TempDir() + info := protocol.DeviceInfo{Alias: "recv", Version: protocol.ProtocolVersion, Port: 53996, Protocol: "http"} + s := New(Options{Info: info, ReceiveDir: dir, AutoAccept: true}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := s.Start(ctx); err != nil { + t.Fatalf("start: %v", err) + } + // Drain transfer events so the buffered channel never matters. + go func() { + for range s.Transfers() { + } + }() + time.Sleep(50 * time.Millisecond) + + base := "http://127.0.0.1:53996" + payload := []byte("hello localsend, this is the file body") + + // prepare-upload + prep := protocol.PrepareUploadRequest{ + Info: protocol.DeviceInfo{Alias: "sender", Fingerprint: "send1234", Version: "2.1"}, + Files: map[string]protocol.FileMetadata{ + "f1": {ID: "f1", FileName: "note.txt", Size: int64(len(payload)), FileType: "text/plain"}, + }, + } + body, _ := json.Marshal(prep) + resp, err := http.Post(base+protocol.PathPrepareUpload, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("prepare-upload: %v", err) + } + var pr protocol.PrepareUploadResponse + if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { + t.Fatalf("decode prepare response: %v", err) + } + resp.Body.Close() + token, ok := pr.Files["f1"] + if !ok || pr.SessionID == "" { + t.Fatalf("missing session/token: %+v", pr) + } + + // upload + url := base + protocol.PathUpload + "?sessionId=" + pr.SessionID + "&fileId=f1&token=" + token + up, err := http.Post(url, "application/octet-stream", bytes.NewReader(payload)) + if err != nil { + t.Fatalf("upload: %v", err) + } + if up.StatusCode != http.StatusOK { + t.Fatalf("upload status = %d", up.StatusCode) + } + up.Body.Close() + + // verify file contents + got, err := os.ReadFile(filepath.Join(dir, "note.txt")) + if err != nil { + t.Fatalf("read received file: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("content mismatch: got %q", got) + } + + // a bad token must be rejected + bad, _ := http.Post(base+protocol.PathUpload+"?sessionId="+pr.SessionID+"&fileId=f1&token=wrong", + "application/octet-stream", bytes.NewReader(payload)) + if bad.StatusCode != http.StatusForbidden { + t.Fatalf("bad token status = %d, want 403", bad.StatusCode) + } + bad.Body.Close() +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..c4472a8 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,349 @@ +// Package server hosts the receiver-side LocalSend HTTP API: discovery +// (/info, /register) plus the upload flow (/prepare-upload, /upload, /cancel). +package server + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "omarchy-send/internal/dbg" + "omarchy-send/internal/protocol" + "omarchy-send/internal/transfer" +) + +// PeerSink records a peer learned from an inbound request (e.g. /register). +type PeerSink func(info protocol.DeviceInfo, ip string) + +// Options configures a Server. +type Options struct { + Info protocol.DeviceInfo + OnPeer PeerSink // optional; called when a peer registers with us + Cert *tls.Certificate // if set, serve TLS (HTTPS / encrypted mode) + ReceiveDir string // where incoming files are written + AutoAccept bool // skip the accept prompt if true + PIN string // if non-empty, senders must supply this PIN +} + +// Server serves the LocalSend HTTP API for this device. +type Server struct { + opts Options + http *http.Server + sessions *sessionStore + + autoAccept atomic.Bool // runtime-toggleable + + // mu guards the runtime-mutable settings below. + mu sync.Mutex + info protocol.DeviceInfo + receiveDir string + pin string + + accepts chan AcceptRequest + transfers chan transfer.Event +} + +// New returns a Server from the given options. +func New(opts Options) *Server { + s := &Server{ + opts: opts, + info: opts.Info, + receiveDir: opts.ReceiveDir, + pin: opts.PIN, + sessions: newSessionStore(), + accepts: make(chan AcceptRequest, 8), + transfers: make(chan transfer.Event, 256), + } + s.autoAccept.Store(opts.AutoAccept) + mux := http.NewServeMux() + mux.HandleFunc(protocol.PathInfo, s.handleInfo) + mux.HandleFunc(protocol.PathRegister, s.handleRegister) + mux.HandleFunc(protocol.PathPrepareUpload, s.handlePrepareUpload) + mux.HandleFunc(protocol.PathUpload, s.handleUpload) + mux.HandleFunc(protocol.PathCancel, s.handleCancel) + s.http = &http.Server{ + Addr: fmt.Sprintf(":%d", opts.Info.Port), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + if opts.Cert != nil { + s.http.TLSConfig = &tls.Config{Certificates: []tls.Certificate{*opts.Cert}} + } + return s +} + +// SetAutoAccept toggles whether incoming transfers skip the accept prompt. +func (s *Server) SetAutoAccept(v bool) { s.autoAccept.Store(v) } + +// AutoAccept reports the current auto-accept state. +func (s *Server) AutoAccept() bool { return s.autoAccept.Load() } + +// SetAlias updates the alias advertised by /info and /register at runtime. +func (s *Server) SetAlias(alias string) { + s.mu.Lock() + s.info.Alias = alias + s.info.DeviceModel = alias + s.mu.Unlock() +} + +// SetReceiveDir updates where incoming files are written at runtime. +func (s *Server) SetReceiveDir(dir string) { + s.mu.Lock() + s.receiveDir = dir + s.mu.Unlock() +} + +// SetPIN updates the required PIN at runtime ("" disables it). +func (s *Server) SetPIN(pin string) { + s.mu.Lock() + s.pin = pin + s.mu.Unlock() +} + +func (s *Server) infoCopy() protocol.DeviceInfo { + s.mu.Lock() + defer s.mu.Unlock() + return s.info +} + +// Accepts returns the channel of incoming upload requests awaiting a decision. +func (s *Server) Accepts() <-chan AcceptRequest { return s.accepts } + +// Transfers returns the channel of incoming-transfer progress events. +func (s *Server) Transfers() <-chan transfer.Event { return s.transfers } + +// Start binds the listener and serves in the background until ctx is cancelled. +func (s *Server) Start(ctx context.Context) error { + ln, err := net.Listen("tcp", s.http.Addr) + if err != nil { + return err + } + go func() { + <-ctx.Done() + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.http.Shutdown(shutCtx) + }() + if s.opts.Cert != nil { + go func() { _ = s.http.ServeTLS(ln, "", "") }() // cert already in TLSConfig + } else { + go func() { _ = s.http.Serve(ln) }() + } + return nil +} + +func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { + writeJSON(w, s.infoCopy()) +} + +// handleRegister records the calling peer and replies with our own info. +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + if s.opts.OnPeer != nil { + var info protocol.DeviceInfo + if err := json.NewDecoder(r.Body).Decode(&info); err == nil && info.Fingerprint != "" { + dbg.Logf("register from %s: alias=%q proto=%s port=%d", clientIP(r), info.Alias, info.Protocol, info.Port) + s.opts.OnPeer(info, clientIP(r)) + } else if err != nil { + dbg.Logf("register from %s: decode error: %v", clientIP(r), err) + } + } + writeJSON(w, s.infoCopy()) +} + +// handlePrepareUpload asks the user to accept, then issues a session + tokens. +func (s *Server) handlePrepareUpload(w http.ResponseWriter, r *http.Request) { + var req protocol.PrepareUploadRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if len(req.Files) == 0 { + w.WriteHeader(http.StatusNoContent) + return + } + if meta, err := json.Marshal(req.Files); err == nil { + dbg.Logf("prepare-upload from %s: alias=%q files=%s", clientIP(r), req.Info.Alias, string(meta)) + } + + // PIN gate: when configured, the sender must supply a matching ?pin=. + s.mu.Lock() + pin := s.pin + s.mu.Unlock() + if pin != "" && r.URL.Query().Get("pin") != pin { + dbg.Logf("prepare-upload from %s: PIN missing/incorrect -> 401", clientIP(r)) + http.Error(w, "pin required", http.StatusUnauthorized) + return + } + + if !s.askAccept(req, clientIP(r)) { + http.Error(w, "rejected", http.StatusForbidden) + return + } + + sess, tokens := s.sessions.create(req.Info, clientIP(r), req.Files) + writeJSON(w, protocol.PrepareUploadResponse{SessionID: sess.id, Files: tokens}) +} + +// askAccept honours auto-accept, or raises an AcceptRequest and blocks for the +// user's decision (with a timeout so a never-answered prompt can't wedge a +// peer's HTTP connection forever). +func (s *Server) askAccept(req protocol.PrepareUploadRequest, ip string) bool { + if s.autoAccept.Load() { + return true + } + var total int64 + for _, f := range req.Files { + total += f.Size + } + reply := make(chan AcceptDecision, 1) + ar := AcceptRequest{From: req.Info, IP: ip, Files: req.Files, TotalSize: total, Reply: reply} + select { + case s.accepts <- ar: + case <-time.After(2 * time.Second): + return false // nobody draining the prompt channel + } + select { + case d := <-reply: + return d.Accept + case <-time.After(60 * time.Second): + return false + } +} + +// handleUpload validates the token and streams the body to the receive dir. +func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + sessionID, fileID, token := q.Get("sessionId"), q.Get("fileId"), q.Get("token") + + sess, fe, ok := s.sessions.lookup(sessionID, fileID, token) + if !ok { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + key := sessionID + ":" + fileID + dest, err := s.writeFile(sess, fe, key, r.Body) + if err != nil { + s.transfers <- transfer.Event{Dir: transfer.Incoming, Kind: transfer.Error, ID: key, FileName: fe.meta.FileName, Err: err} + http.Error(w, "write failed", http.StatusInternalServerError) + return + } + dbg.Logf("received %q -> %s", fe.meta.FileName, dest) + s.transfers <- transfer.Event{Dir: transfer.Incoming, Kind: transfer.FileDone, ID: key, FileName: fe.meta.FileName, Received: fe.meta.Size, Total: fe.meta.Size} + s.sessions.complete(sessionID, fileID) + w.WriteHeader(http.StatusOK) +} + +// writeFile streams r to a uniquely-named file in the receive dir, emitting +// throttled progress events under the transfer key, and returns the final path. +// It writes to a temp file and renames on success so partial transfers never +// masquerade as complete. +func (s *Server) writeFile(sess *session, fe *fileEntry, key string, r io.Reader) (string, error) { + s.mu.Lock() + dir := s.receiveDir + s.mu.Unlock() + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + dest := uniquePath(dir, fe.meta.FileName) + tmp := dest + ".part" + + f, err := os.Create(tmp) + if err != nil { + return "", err + } + + pr := &progressReader{ + r: r, + total: fe.meta.Size, + ctx: sess.ctx, + emit: func(received int64) { + select { + case s.transfers <- transfer.Event{Dir: transfer.Incoming, Kind: transfer.Progress, ID: key, FileName: fe.meta.FileName, Received: received, Total: fe.meta.Size}: + default: + } + }, + } + s.transfers <- transfer.Event{Dir: transfer.Incoming, Kind: transfer.Start, ID: key, FileName: fe.meta.FileName, Total: fe.meta.Size} + + _, copyErr := io.Copy(f, pr) + closeErr := f.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(tmp) + if copyErr != nil { + return "", copyErr + } + return "", closeErr + } + if err := os.Rename(tmp, dest); err != nil { + return "", err + } + return dest, nil +} + +func (s *Server) handleCancel(w http.ResponseWriter, r *http.Request) { + sessionID := r.URL.Query().Get("sessionId") + s.sessions.cancel(sessionID) + s.transfers <- transfer.Event{Dir: transfer.Incoming, Kind: transfer.Cancel, ID: sessionID} + 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" + } + candidate := filepath.Join(dir, base) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + 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 + } + } +} + +func clientIP(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +// LocalIPs returns this host's non-loopback IPv4 addresses, for display. +func LocalIPs() []string { + var out []string + addrs, err := net.InterfaceAddrs() + if err != nil { + return out + } + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok || ipnet.IP.IsLoopback() { + continue + } + if ip4 := ipnet.IP.To4(); ip4 != nil { + out = append(out, ip4.String()) + } + } + return out +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..6c6e95e --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,52 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "omarchy-send/internal/protocol" +) + +func TestInfoEndpoint(t *testing.T) { + info := protocol.DeviceInfo{ + Alias: "test-host", + Version: protocol.ProtocolVersion, + DeviceType: protocol.DeviceServer, + Fingerprint: "deadbeef", + Port: 53999, // off the default to avoid clashing with a running instance + Protocol: "http", + } + s := New(Options{Info: info}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := s.Start(ctx); err != nil { + t.Fatalf("start: %v", err) + } + // Give Serve a beat to accept connections. + time.Sleep(50 * time.Millisecond) + + resp, err := http.Get("http://127.0.0.1:53999" + protocol.PathInfo) + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var got protocol.DeviceInfo + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Alias != "test-host" || got.Version != "2.1" || got.Fingerprint != "deadbeef" { + t.Fatalf("unexpected info: %+v", got) + } + if got.Protocol != "http" { + t.Fatalf("protocol = %q, want http", got.Protocol) + } +} diff --git a/internal/server/session.go b/internal/server/session.go new file mode 100644 index 0000000..8a7f258 --- /dev/null +++ b/internal/server/session.go @@ -0,0 +1,118 @@ +package server + +import ( + "context" + "crypto/rand" + "encoding/hex" + "sync" + + "omarchy-send/internal/protocol" +) + +// fileEntry tracks one file within a session. +type fileEntry struct { + meta protocol.FileMetadata + token string + done bool +} + +// session is one accepted prepare-upload, holding per-file tokens and a cancel +// hook that aborts in-flight writes. +type session struct { + id string + peer protocol.DeviceInfo + ip string + files map[string]*fileEntry // by fileId + ctx context.Context + cancel context.CancelFunc +} + +// sessionStore is the concurrency-safe registry of active sessions. +type sessionStore struct { + mu sync.Mutex + sessions map[string]*session +} + +func newSessionStore() *sessionStore { + return &sessionStore{sessions: make(map[string]*session)} +} + +// randToken returns a random 32-hex-char token/id. +func randToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// create builds a session for the given files and returns it plus the +// fileId->token map for the prepare-upload response. +func (s *sessionStore) create(peer protocol.DeviceInfo, ip string, files map[string]protocol.FileMetadata) (*session, map[string]string) { + ctx, cancel := context.WithCancel(context.Background()) + sess := &session{ + id: randToken(), + peer: peer, + ip: ip, + files: make(map[string]*fileEntry, len(files)), + ctx: ctx, + cancel: cancel, + } + tokens := make(map[string]string, len(files)) + for fileID, meta := range files { + tok := randToken() + sess.files[fileID] = &fileEntry{meta: meta, token: tok} + tokens[fileID] = tok + } + + s.mu.Lock() + s.sessions[sess.id] = sess + s.mu.Unlock() + return sess, tokens +} + +// lookup returns the session and file entry for an upload, validating the token. +func (s *sessionStore) lookup(sessionID, fileID, token string) (*session, *fileEntry, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.sessions[sessionID] + if !ok { + return nil, nil, false + } + fe, ok := sess.files[fileID] + if !ok || fe.token != token { + return nil, nil, false + } + return sess, fe, true +} + +// cancel aborts a session's in-flight writes and removes it. +func (s *sessionStore) cancel(sessionID string) { + s.mu.Lock() + sess, ok := s.sessions[sessionID] + if ok { + delete(s.sessions, sessionID) + } + s.mu.Unlock() + if ok { + sess.cancel() + } +} + +// complete marks a file done and, if all files are done, removes the session. +func (s *sessionStore) complete(sessionID, fileID string) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.sessions[sessionID] + if !ok { + return + } + if fe, ok := sess.files[fileID]; ok { + fe.done = true + } + for _, fe := range sess.files { + if !fe.done { + return + } + } + sess.cancel() + delete(s.sessions, sessionID) +} diff --git a/internal/server/tls_test.go b/internal/server/tls_test.go new file mode 100644 index 0000000..c334620 --- /dev/null +++ b/internal/server/tls_test.go @@ -0,0 +1,65 @@ +package server + +import ( + "context" + "crypto/tls" + "encoding/json" + "net/http" + "testing" + "time" + + "omarchy-send/internal/protocol" + "omarchy-send/internal/security" +) + +// TestTLSFingerprintMatches is the core HTTPS-interop guarantee: the +// fingerprint we advertise must equal the SHA-256 of the certificate our TLS +// server actually presents, so a peer that pins the announced fingerprint +// validates us. +func TestTLSFingerprintMatches(t *testing.T) { + id, err := security.Generate() + if err != nil { + t.Fatalf("generate: %v", err) + } + cert, err := tls.X509KeyPair([]byte(id.CertPEM), []byte(id.KeyPEM)) + if err != nil { + t.Fatalf("keypair: %v", err) + } + + info := protocol.DeviceInfo{ + Alias: "tls-host", Version: protocol.ProtocolVersion, + Fingerprint: id.Fingerprint, Port: 53997, Protocol: "https", + } + s := New(Options{Info: info, Cert: &cert}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := s.Start(ctx); err != nil { + t.Fatalf("start: %v", err) + } + time.Sleep(50 * time.Millisecond) + + client := &http.Client{ + Timeout: 2 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, + } + resp, err := client.Get("https://127.0.0.1:53997" + protocol.PathInfo) + if err != nil { + t.Fatalf("https get: %v", err) + } + defer resp.Body.Close() + + // The cert the server presented must hash to the advertised fingerprint. + served := resp.TLS.PeerCertificates[0].Raw + if got := security.Fingerprint(served); got != id.Fingerprint { + t.Fatalf("served cert fingerprint %s != advertised %s", got, id.Fingerprint) + } + + var di protocol.DeviceInfo + if err := json.NewDecoder(resp.Body).Decode(&di); err != nil { + t.Fatalf("decode: %v", err) + } + if di.Protocol != "https" || di.Fingerprint != id.Fingerprint { + t.Fatalf("unexpected /info over TLS: %+v", di) + } +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000..1effbc2 --- /dev/null +++ b/internal/theme/theme.go @@ -0,0 +1,97 @@ +// Package theme loads the active Omarchy colour scheme so the TUI matches the +// rest of the desktop. Omarchy writes a per-theme colors.toml (accent, +// foreground, background, color0-15); we read the active one and fall back to a +// sensible built-in palette on headless / non-Omarchy systems. +package theme + +import ( + "os" + "path/filepath" + "strings" +) + +// Theme is the small set of colours the TUI needs, as "#rrggbb" strings. +type Theme struct { + Accent string + Fg string + Bg string + Dim string + Muted string + Good string + Bad string +} + +// Default is the palette used when no Omarchy theme is found (e.g. a headless / +// omaterm box reached over SSH). It uses ANSI palette indices rather than fixed +// hex, so the colours track whatever theme the connecting terminal uses. +func Default() Theme { + return Theme{ + Accent: "4", // blue + Fg: "7", // foreground / white + Bg: "0", // background / black + Dim: "7", + Muted: "8", // bright black / grey + Good: "2", // green + Bad: "1", // red + } +} + +// Load returns the active Omarchy theme's colours, or Default() if unavailable. +func Load() Theme { + home, err := os.UserHomeDir() + if err != nil { + return Default() + } + path := filepath.Join(home, ".config", "omarchy", "current", "theme", "colors.toml") + kv, ok := parse(path) + if !ok { + return Default() + } + d := Default() + pick := func(def string, keys ...string) string { + for _, k := range keys { + if v := kv[k]; v != "" { + return v + } + } + return def + } + return Theme{ + Accent: pick(d.Accent, "accent", "color4"), + Fg: pick(d.Fg, "foreground", "color7"), + Bg: pick(d.Bg, "background", "color0"), + Dim: pick(d.Dim, "color7", "foreground"), + Muted: pick(d.Muted, "color8", "color7"), + Good: pick(d.Good, "color2"), + Bad: pick(d.Bad, "color1"), + } +} + +// parse reads simple `key = "#hex"` lines from an Omarchy colors.toml. It is a +// minimal parser (no TOML dependency) sufficient for that flat file. +func parse(path string) (map[string]string, bool) { + data, err := os.ReadFile(path) + if err != nil { + return nil, false + } + kv := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + eq := strings.IndexByte(line, '=') + if eq < 0 { + continue + } + key := strings.TrimSpace(line[:eq]) + val := strings.Trim(strings.TrimSpace(line[eq+1:]), `"`) + if strings.HasPrefix(val, "#") { + kv[key] = val + } + } + if len(kv) == 0 { + return nil, false + } + return kv, true +} diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go new file mode 100644 index 0000000..0a94caa --- /dev/null +++ b/internal/theme/theme_test.go @@ -0,0 +1,30 @@ +package theme + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLoadParsesActiveTheme verifies the parser against the real Omarchy +// colors.toml when present; otherwise it confirms the Default fallback. +func TestLoadParsesActiveTheme(t *testing.T) { + home, _ := os.UserHomeDir() + path := filepath.Join(home, ".config", "omarchy", "current", "theme", "colors.toml") + got := Load() + + if _, err := os.Stat(path); err != nil { + if got != Default() { + t.Fatalf("no theme file but Load() != Default(): %+v", got) + } + t.Skip("no active Omarchy theme on this machine; Default() used") + } + + for _, c := range []string{got.Accent, got.Fg, got.Bg, got.Good, got.Bad} { + if !strings.HasPrefix(c, "#") || len(c) != 7 { + t.Errorf("bad colour %q", c) + } + } + t.Logf("loaded theme: %+v", got) +} diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go new file mode 100644 index 0000000..476c313 --- /dev/null +++ b/internal/transfer/transfer.go @@ -0,0 +1,41 @@ +// Package transfer defines the shared progress-event vocabulary used by both +// the receiver (server) and the sender (client), so the TUI renders incoming +// and outgoing transfers uniformly. +package transfer + +import "errors" + +// ErrPinRequired is reported by the sender when a peer rejects prepare-upload +// with 401, i.e. it needs a PIN. The TUI prompts for one and retries. +var ErrPinRequired = errors.New("pin required") + +// Direction is whether a transfer is incoming (we receive) or outgoing (we send). +type Direction int + +const ( + Incoming Direction = iota + Outgoing +) + +// Kind classifies a transfer lifecycle event. +type Kind int + +const ( + Start Kind = iota + Progress + FileDone + Error + Cancel +) + +// Event reports the progress/lifecycle of one file. A transfer row is keyed by +// ID (sessionId:fileId). +type Event struct { + Dir Direction + Kind Kind + ID string + FileName string + Received int64 + Total int64 + Err error +} diff --git a/internal/tui/manage_test.go b/internal/tui/manage_test.go new file mode 100644 index 0000000..172f148 --- /dev/null +++ b/internal/tui/manage_test.go @@ -0,0 +1,112 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "omarchy-send/internal/config" +) + +// manageModel returns a model whose receive dir is a temp dir seeded with the +// given filenames, already switched to the Manage tab. +func manageModel(t *testing.T, names ...string) (Model, string) { + t.Helper() + dir := t.TempDir() + for _, n := range names { + if err := os.WriteFile(filepath.Join(dir, n), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + cfg := config.Config{Alias: "omarchy", Port: 53317, ReceiveDir: dir, Protocol: "https"} + m := New(cfg, nil) + nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = nm.(Model) + nm, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")}) // enter Manage + return nm.(Model), dir +} + +func key(m Model, s string) Model { + var msg tea.KeyMsg + switch s { + case " ": + msg = tea.KeyMsg{Type: tea.KeySpace} + case "enter": + msg = tea.KeyMsg{Type: tea.KeyEnter} + default: + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } + nm, _ := m.Update(msg) + return nm.(Model) +} + +func TestManageListsReceivedFilesSkippingPartials(t *testing.T) { + m, _ := manageModel(t, "photo.dng", "clip.mp4", "incoming.iso.part") + got := len(m.fileList.Items()) + if got != 2 { + t.Fatalf("expected 2 listed files (.part skipped), got %d", got) + } + out := m.View() + for _, want := range []string{"Manage", "photo.dng", "clip.mp4"} { + if !strings.Contains(out, want) { + t.Errorf("manage view missing %q", want) + } + } + if strings.Contains(out, ".part") { + t.Error("in-progress .part file should not be shown") + } +} + +func TestManageDeleteSingleViaConfirm(t *testing.T) { + m, dir := manageModel(t, "keep.txt", "drop.txt") + // Cursor starts on the newest (drop.txt was written last). Marking it and + // confirming should remove exactly that file. + m = key(m, " ") // mark file under cursor + if len(m.marked) != 1 { + t.Fatalf("expected 1 marked, got %d", len(m.marked)) + } + m = key(m, "d") // request delete -> confirm card + if !m.confirmDel { + t.Fatal("expected confirm card to be showing") + } + m = key(m, "y") // confirm + if m.confirmDel { + t.Error("confirm card should be dismissed after delete") + } + if len(m.fileList.Items()) != 1 { + t.Fatalf("expected 1 file left, got %d", len(m.fileList.Items())) + } + // One file gone, one remains on disk. + remaining, _ := os.ReadDir(dir) + if len(remaining) != 1 { + t.Fatalf("expected 1 file on disk, got %d", len(remaining)) + } +} + +func TestManageDeleteAllAndCancel(t *testing.T) { + m, dir := manageModel(t, "a.txt", "b.txt", "c.txt") + m = key(m, "a") // mark all + if len(m.marked) != 3 { + t.Fatalf("expected 3 marked, got %d", len(m.marked)) + } + m = key(m, "d") + m = key(m, "n") // cancel + if m.confirmDel { + t.Error("cancel should dismiss the confirm card") + } + if files, _ := os.ReadDir(dir); len(files) != 3 { + t.Fatalf("cancel must not delete anything; have %d files", len(files)) + } + // Now actually delete all. + m = key(m, "d") + m = key(m, "y") + if files, _ := os.ReadDir(dir); len(files) != 0 { + t.Fatalf("expected all files deleted, %d remain", len(files)) + } + if len(m.fileList.Items()) != 0 { + t.Errorf("list should be empty after deleting all") + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..d23ff85 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,1040 @@ +// Package tui implements the Bubble Tea front end. It routes between Devices, +// Transfers, Manage (received-file housekeeping) and Settings screens, offers a +// file picker for sending, and raises modal prompts for incoming files and for +// confirming deletions. +package tui + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/filepicker" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "omarchy-send/internal/app" + "omarchy-send/internal/config" + "omarchy-send/internal/discovery" + "omarchy-send/internal/server" + "omarchy-send/internal/theme" + "omarchy-send/internal/transfer" +) + +// Controller lets the TUI drive the network layer. +type Controller interface { + Announce() + Send(peer discovery.Peer, paths []string, pin string) + SetAutoAccept(bool) + SetAlias(string) + SetReceiveDir(string) + SetPIN(string) +} + +type screen int + +const ( + screenPeers screen = iota + screenTransfers + screenManage + screenSettings + screenPicker // entered from Peers; not part of the tab rotation + + tabCount = 4 // Devices · Transfers · Manage · Settings +) + +// xfer is one row in the transfers view. +type xfer struct { + key string + name string + dir transfer.Direction + received int64 + total int64 + state string // active | done | error | cancelled + started time.Time +} + +// finished reports whether the transfer is in a terminal state. +func (x *xfer) finished() bool { + return x.state == "done" || x.state == "error" || x.state == "cancelled" +} + +// Model is the root router model. +type Model struct { + cfg config.Config + ctrl Controller + ips []string + screen screen + + peerList list.Model + peers map[string]discovery.Peer + + picker filepicker.Model + staged []string // file paths queued to send + target *discovery.Peer // peer we're sending to + + bar progress.Model + transfers []*xfer + xferIndex map[string]*xfer + + pending *server.AcceptRequest // non-nil while an accept prompt is showing + + // Manage (received files) tab. + fileList list.Model + marked map[string]bool // paths queued for deletion, shared with fileDelegate + manageErr string // last load/delete error, shown beneath the list + confirmDel bool // a delete-confirmation card is showing + delTargets []string // paths the pending confirmation will delete + + autoAccept bool + + // Settings edit form. + editing bool + editFocus int + editInputs []textinput.Model // 0=alias, 1=receive dir, 2=pin + + // PIN prompt state for sending to a PIN-protected peer. + pinInput textinput.Model + showPin bool + sendPeer *discovery.Peer + sendPaths []string + + width, height int + quitting bool +} + +// New returns the root model. ctrl may be nil (e.g. in tests). +func New(cfg config.Config, ctrl Controller) Model { + applyTheme(theme.Load()) // match the active Omarchy theme + + l := list.New(nil, deviceDelegate{icons: !cfg.NoIcons}, 0, 0) + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + + marked := make(map[string]bool) + fl := list.New(nil, fileDelegate{marked: marked}, 0, 0) + fl.SetShowTitle(false) + fl.SetShowHelp(false) + fl.SetShowStatusBar(false) + + fp := filepicker.New() + if home, err := os.UserHomeDir(); err == nil { + fp.CurrentDirectory = home + } + fp.AutoHeight = false + fp.Styles.Cursor = fp.Styles.Cursor.Foreground(accent) + fp.Styles.Selected = fp.Styles.Selected.Foreground(accent).Bold(true) + fp.Styles.Directory = fp.Styles.Directory.Foreground(accent) + fp.Styles.File = fp.Styles.File.Foreground(text) + fp.Styles.FileSize = fp.Styles.FileSize.Foreground(muted) + fp.Styles.Permission = fp.Styles.Permission.Foreground(muted) + fp.Styles.Symlink = fp.Styles.Symlink.Foreground(dim) + fp.Styles.EmptyDirectory = fp.Styles.EmptyDirectory.Foreground(muted) + + pin := textinput.New() + pin.Placeholder = "PIN" + pin.CharLimit = 16 + + mkInput := func(placeholder string, limit int) textinput.Model { + ti := textinput.New() + ti.Placeholder = placeholder + ti.CharLimit = limit + ti.Width = 48 + return ti + } + editInputs := []textinput.Model{ + mkInput("alias", 63), + mkInput("receive directory", 256), + mkInput("PIN (blank = disabled)", 16), + } + + return Model{ + cfg: cfg, + ctrl: ctrl, + ips: server.LocalIPs(), + screen: screenPeers, + peerList: l, + peers: make(map[string]discovery.Peer), + fileList: fl, + marked: marked, + picker: fp, + bar: progress.New(progress.WithDefaultGradient(), progress.WithWidth(22)), + xferIndex: make(map[string]*xfer), + autoAccept: cfg.AutoAccept, + pinInput: pin, + editInputs: editInputs, + } +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + iw, ih := innerDims(msg.Width, msg.Height) + lh := ih - 2 // leave room for the column-header row + blank + if lh < 1 { + lh = 1 + } + m.peerList.SetSize(iw, lh) + m.fileList.SetSize(iw, lh) + if ph := ih - 8; ph >= 3 { + m.picker.Height = ph + } else { + m.picker.Height = 3 + } + return m, nil + + case app.PeerFoundMsg: + m.peers[msg.Peer.Info.Fingerprint] = msg.Peer + m.peerList.SetItems(m.peerItems()) + return m, nil + + case app.PeerLostMsg: + delete(m.peers, msg.Fingerprint) + m.peerList.SetItems(m.peerItems()) + return m, nil + + case app.IncomingMsg: + req := msg.Req + m.pending = &req + return m, nil + + case app.TransferMsg: + // A PIN-required signal opens the PIN prompt instead of a transfer row. + if msg.Ev.Kind == transfer.Error && errors.Is(msg.Ev.Err, transfer.ErrPinRequired) { + m.showPin = true + m.pinInput.SetValue("") + m.pinInput.Focus() + return m, textinput.Blink + } + m.applyTransfer(msg.Ev) + return m, nil + + case tea.KeyMsg: + if m.showPin { + return m.updatePin(msg) + } + if m.editing { + return m.updateSettingsEdit(msg) + } + if m.confirmDel { + return m.updateConfirmDelete(msg) + } + if m.pending != nil { + return m.updateAccept(msg) + } + if m.screen == screenPicker { + return m.updatePicker(msg) + } + if m.screen == screenPeers && m.peerList.FilterState() == list.Filtering { + break + } + if m.screen == screenManage && m.fileList.FilterState() == list.Filtering { + break + } + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + case "1": + m.screen = screenPeers + return m, nil + case "2": + m.screen = screenTransfers + return m, nil + case "3": + m.screen = screenManage + m.refreshManage() + return m, nil + case "4": + m.screen = screenSettings + return m, nil + case "tab": + m.screen = (m.screen + 1) % tabCount + if m.screen == screenManage { + m.refreshManage() + } + return m, nil + case "shift+tab": + m.screen = (m.screen + tabCount - 1) % tabCount + if m.screen == screenManage { + m.refreshManage() + } + return m, nil + case "r": + if m.screen == screenPeers && m.ctrl != nil { + m.ctrl.Announce() + } + if m.screen == screenManage { + m.refreshManage() + } + return m, nil + case "c": + if m.screen == screenTransfers { + m.clearFinished() + } + return m, nil + case " ": + if m.screen == screenManage { + m.toggleMark() + } + return m, nil + case "d", "x": + if m.screen == screenManage { + return m.requestDelete() + } + return m, nil + case "a": + if m.screen == screenManage { + m.toggleMarkAll() + return m, nil + } + if m.screen == screenSettings { + m.autoAccept = !m.autoAccept + if m.ctrl != nil { + m.ctrl.SetAutoAccept(m.autoAccept) + } + } + return m, nil + case "i": + if m.screen == screenSettings { + m.cfg.NoIcons = !m.cfg.NoIcons + m.peerList.SetDelegate(deviceDelegate{icons: !m.cfg.NoIcons}) + _ = m.cfg.Save() + } + return m, nil + case "e": + if m.screen == screenSettings { + return m.beginEdit() + } + return m, nil + case "enter": + if m.screen == screenPeers { + if it, ok := m.peerList.SelectedItem().(peerItem); ok { + peer := it.p + m.target = &peer + m.staged = nil + m.screen = screenPicker + return m, m.picker.Init() + } + } + } + } + + if m.pending == nil && m.screen == screenPicker { + var cmd tea.Cmd + m.picker, cmd = m.picker.Update(msg) + return m, cmd + } + if m.pending == nil && m.screen == screenPeers { + var cmd tea.Cmd + m.peerList, cmd = m.peerList.Update(msg) + return m, cmd + } + if m.pending == nil && !m.confirmDel && m.screen == screenManage { + var cmd tea.Cmd + m.fileList, cmd = m.fileList.Update(msg) + return m, cmd + } + return m, nil +} + +// updatePicker handles the file picker / staging screen. +func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.screen = screenPeers + return m, nil + case "ctrl+c", "q": + m.quitting = true + return m, tea.Quit + case "backspace": + if len(m.staged) > 0 { + m.staged = m.staged[:len(m.staged)-1] + } + return m, nil + case "S": + if len(m.staged) > 0 && m.target != nil && m.ctrl != nil { + m.sendPeer = m.target + m.sendPaths = m.staged + m.ctrl.Send(*m.target, m.staged, "") + m.staged = nil + m.screen = screenTransfers + } + return m, nil + } + + var cmd tea.Cmd + m.picker, cmd = m.picker.Update(msg) + if ok, path := m.picker.DidSelectFile(msg); ok { + if !contains(m.staged, path) { + m.staged = append(m.staged, path) + } + } + return m, cmd +} + +// beginEdit enters the settings edit form, prefilling current values. +func (m Model) beginEdit() (tea.Model, tea.Cmd) { + m.editing = true + m.editFocus = 0 + m.editInputs[0].SetValue(m.cfg.Alias) + m.editInputs[1].SetValue(m.cfg.ReceiveDir) + m.editInputs[2].SetValue(m.cfg.PIN) + for i := range m.editInputs { + m.editInputs[i].Blur() + } + m.editInputs[0].Focus() + return m, textinput.Blink +} + +// updateSettingsEdit drives the settings edit form. +func (m Model) updateSettingsEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.editing = false + return m, nil + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "ctrl+s": + return m.saveEdit() + case "tab", "down": + m.editFocus = (m.editFocus + 1) % len(m.editInputs) + m.focusEdit() + return m, textinput.Blink + case "shift+tab", "up": + m.editFocus = (m.editFocus - 1 + len(m.editInputs)) % len(m.editInputs) + m.focusEdit() + return m, textinput.Blink + case "enter": + // Enter on the last field saves; otherwise advances. + if m.editFocus == len(m.editInputs)-1 { + return m.saveEdit() + } + m.editFocus++ + m.focusEdit() + return m, textinput.Blink + } + var cmd tea.Cmd + m.editInputs[m.editFocus], cmd = m.editInputs[m.editFocus].Update(msg) + return m, cmd +} + +func (m *Model) focusEdit() { + for i := range m.editInputs { + if i == m.editFocus { + m.editInputs[i].Focus() + } else { + m.editInputs[i].Blur() + } + } +} + +// saveEdit persists the form to config and applies it live. +func (m Model) saveEdit() (tea.Model, tea.Cmd) { + alias := strings.TrimSpace(m.editInputs[0].Value()) + dir := strings.TrimSpace(m.editInputs[1].Value()) + pin := strings.TrimSpace(m.editInputs[2].Value()) + if alias != "" { + m.cfg.Alias = alias + m.cfg.DeviceModel = alias + } + if dir != "" { + m.cfg.ReceiveDir = dir + } + m.cfg.PIN = pin + _ = m.cfg.Save() + if m.ctrl != nil { + m.ctrl.SetAlias(m.cfg.Alias) + m.ctrl.SetReceiveDir(m.cfg.ReceiveDir) + m.ctrl.SetPIN(m.cfg.PIN) + } + m.editing = false + return m, nil +} + +// updatePin handles the PIN entry prompt shown when a peer needs a PIN. +func (m Model) updatePin(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + pin := strings.TrimSpace(m.pinInput.Value()) + m.showPin = false + m.pinInput.Blur() + if pin != "" && m.sendPeer != nil && m.ctrl != nil { + m.ctrl.Send(*m.sendPeer, m.sendPaths, pin) + m.screen = screenTransfers + } + return m, nil + case "esc", "ctrl+c": + m.showPin = false + m.pinInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.pinInput, cmd = m.pinInput.Update(msg) + return m, cmd +} + +func (m Model) updateAccept(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y", "enter": + m.pending.Reply <- server.AcceptDecision{Accept: true} + m.pending = nil + m.screen = screenTransfers + case "n", "N", "esc": + m.pending.Reply <- server.AcceptDecision{Accept: false} + m.pending = nil + } + return m, nil +} + +func (m *Model) applyTransfer(ev transfer.Event) { + x, ok := m.xferIndex[ev.ID] + if !ok { + x = &xfer{key: ev.ID, name: ev.FileName, dir: ev.Dir, total: ev.Total, state: "active", started: time.Now()} + m.xferIndex[ev.ID] = x + m.transfers = append(m.transfers, x) + } + if ev.FileName != "" { + x.name = ev.FileName + } + if ev.Total > 0 { + x.total = ev.Total + } + switch ev.Kind { + case transfer.Start, transfer.Progress: + x.received = ev.Received + case transfer.FileDone: + x.received = x.total + x.state = "done" + case transfer.Error: + x.state = "error" + case transfer.Cancel: + x.state = "cancelled" + } +} + +func (m Model) peerItems() []list.Item { + ps := make([]discovery.Peer, 0, len(m.peers)) + for _, p := range m.peers { + ps = append(ps, p) + } + sort.Slice(ps, func(i, j int) bool { return ps[i].Info.Alias < ps[j].Info.Alias }) + items := make([]list.Item, len(ps)) + for i, p := range ps { + items[i] = peerItem{p: p} + } + return items +} + +// innerDims returns the content width/height inside the bordered frame for a +// given window size: 1 title row + 1 tab row + 1 footer row, plus a 1-cell +// border and 1-cell horizontal padding on each side. +func innerDims(w, h int) (iw, ih int) { + if w <= 0 { + w = 90 + } + if h <= 0 { + h = 28 + } + iw = w - 4 + if iw < 20 { + iw = 20 + } + ih = h - 5 + if ih < 3 { + ih = 3 + } + return iw, ih +} + +func (m Model) View() string { + if m.quitting { + return "" + } + w, h := m.width, m.height + if w <= 0 { + w = 90 + } + if h <= 0 { + h = 28 + } + + // Focused prompts take over the window as a centered card. + if m.showPin { + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.pinView())) + } + if m.pending != nil { + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.acceptView())) + } + if m.confirmDel { + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.confirmDeleteView())) + } + + cw, ih := innerDims(w, h) + var body string + center := false // center short content; let populated lists fill top-down + switch m.screen { + case screenPeers: + if len(m.peers) == 0 { + body, center = headerStyle.Render("Searching for devices on the network…"), true + } else { + body = deviceHeader() + "\n" + m.peerList.View() + } + case screenTransfers: + if len(m.transfers) == 0 { + body, center = headerStyle.Render("No transfers yet.\nIncoming and outgoing files appear here."), true + } else { + body = transferHeader() + "\n\n" + m.transfersView() + } + case screenManage: + switch { + case m.manageErr != "" && len(m.fileList.Items()) == 0: + body, center = lipgloss.NewStyle().Foreground(bad).Render(m.manageErr), true + case len(m.fileList.Items()) == 0: + body, center = headerStyle.Render("No received files.\nFiles sent to this device appear here."), true + default: + body = m.manageView() + } + case screenSettings: + if m.editing { + body, center = m.settingsEditView(), true + } else { + body, center = m.settingsView(), true + } + case screenPicker: + body = m.pickerView() + } + if center { + body = centerIn(cw, ih, body) + } + // lipgloss Width includes padding but not the border, so the frame width is + // w-2 (border) while the content area inside the padding is w-4. + panel := frameStyle.Width(w - 2).Height(ih).Render(body) + return lipgloss.JoinVertical(lipgloss.Left, + m.titleBar(w), + m.tabBar(), + panel, + footerStyle.Render(m.footerText()), + ) +} + +// centerIn centers a multi-line block within w×h, keeping the block's lines +// left-aligned relative to each other (it pads every line to the block's width +// first, so columns stay aligned rather than each line centering on its own). +func centerIn(w, h int, s string) string { + block := lipgloss.NewStyle().Width(lipgloss.Width(s)).Render(s) + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, block) +} + +// titleBar is the full-width accent bar: app name on the left, alias·IP on the right. +func (m Model) titleBar(w int) string { + left := " Omarchy-Send" + right := m.cfg.Alias + if len(m.ips) > 0 { + right += " · " + m.ips[0] + } + right += " " + gap := w - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + gap = 1 + } + return titleBarStyle.Width(w).Render(left + strings.Repeat(" ", gap) + right) +} + +func (m Model) tabBar() string { + tab := func(label string, s screen) string { + if m.screen == s { + return tabActiveStyle.Render(label) + } + return tabInactiveStyle.Render(label) + } + return " " + tab("Devices", screenPeers) + tab("Transfers", screenTransfers) + tab("Manage", screenManage) + tab("Settings", screenSettings) +} + +func (m Model) pickerView() string { + target := "" + if m.target != nil { + target = m.target.Info.Alias + } + var b strings.Builder + b.WriteString(titleStyle.Render("Send to " + target)) + b.WriteString(" ") + b.WriteString(headerStyle.Render(collapseHome(m.picker.CurrentDirectory))) + b.WriteString("\n\n") + b.WriteString(m.picker.View()) + b.WriteString("\n") + b.WriteString(m.stagedPanel()) + return b.String() +} + +// stagedPanel renders the queued files as a bordered box (or a hint when empty). +func (m Model) stagedPanel() string { + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + 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.")) + } + 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))) + } + return border.Render(b.String()) +} + +func (m Model) pinView() string { + target := "" + if m.sendPeer != nil { + target = m.sendPeer.Info.Alias + } + var b strings.Builder + b.WriteString(titleStyle.Render("PIN required")) + b.WriteString("\n\n") + b.WriteString(headerStyle.Render(target + " requires a PIN.")) + b.WriteString("\n\n") + b.WriteString(m.pinInput.View()) + b.WriteString("\n\n") + b.WriteString(footerStyle.Render("enter send · esc cancel")) + return b.String() +} + +func (m Model) acceptView() string { + var b strings.Builder + b.WriteString(titleStyle.Render("Incoming files")) + b.WriteString("\n\n") + b.WriteString(valueStyle.Render(m.pending.From.Alias)) + b.WriteString(headerStyle.Render(fmt.Sprintf(" · %d file(s) · %s", len(m.pending.Files), humanBytes(m.pending.TotalSize)))) + b.WriteString("\n\n") + names := make([]string, 0, len(m.pending.Files)) + for _, f := range m.pending.Files { + names = append(names, f.FileName) + } + sort.Strings(names) + for _, n := range names { + b.WriteString(" • " + n + "\n") + } + b.WriteString("\n") + b.WriteString(footerStyle.Render("y/enter accept · n/esc reject")) + return b.String() +} + +// transferHeader is the dim column-header row shown above the transfers list. +func transferHeader() string { + h := lipgloss.NewStyle().Foreground(muted) + return " " + h.Width(24).Render("Name") + h.Width(25).Render("Progress") + h.Render("Size") +} + +func (m Model) transfersView() string { + var b strings.Builder + for _, x := range m.transfers { + ratio := 0.0 + if x.total > 0 { + ratio = float64(x.received) / float64(x.total) + } + if ratio > 1 { + ratio = 1 + } + arrow := "↓" + if x.dir == transfer.Outgoing { + arrow = "↑" + } + var status string + switch x.state { + case "active": + status = fmt.Sprintf("%s/%s", humanBytes(x.received), humanBytes(x.total)) + case "done": + status = lipgloss.NewStyle().Foreground(good).Render("✓ done") + case "error": + status = lipgloss.NewStyle().Foreground(bad).Render("✗ error") + case "cancelled": + status = lipgloss.NewStyle().Foreground(muted).Render("cancelled") + } + name := lipgloss.NewStyle().Foreground(text).Width(22).Render(truncate(x.name, 22)) + b.WriteString(fmt.Sprintf("%s %s %s %s\n", arrow, name, m.bar.ViewAs(ratio), status)) + } + return b.String() +} + +// rateETA returns a "1.2 MiB/s · 3s left" string for an active transfer. +func (m Model) rateETA(x *xfer) string { + elapsed := time.Since(x.started).Seconds() + if elapsed < 0.3 || x.received == 0 { + return "" + } + rate := float64(x.received) / elapsed // bytes/sec + out := fmt.Sprintf("%s/s", humanBytes(int64(rate))) + if x.total > x.received && rate > 0 { + eta := time.Duration(float64(x.total-x.received)/rate) * time.Second + out += fmt.Sprintf(" · %s left", eta.Round(time.Second)) + } + return out +} + +// refreshManage reloads the received-files list and prunes marks for files that +// no longer exist on disk. +func (m *Model) refreshManage() { + items, err := receivedFiles(m.cfg.ReceiveDir) + if err != nil { + m.manageErr = err.Error() + } else { + m.manageErr = "" + } + m.fileList.SetItems(items) + live := make(map[string]bool, len(items)) + for _, raw := range items { + live[raw.(fileItem).path] = true + } + for p := range m.marked { + if !live[p] { + delete(m.marked, p) + } + } +} + +// toggleMark flips the deletion mark on the file under the cursor. +func (m *Model) toggleMark() { + it, ok := m.fileList.SelectedItem().(fileItem) + if !ok { + return + } + if m.marked[it.path] { + delete(m.marked, it.path) + } else { + m.marked[it.path] = true + } +} + +// toggleMarkAll marks every listed file, or clears all marks if they are +// already fully marked. +func (m *Model) toggleMarkAll() { + items := m.fileList.Items() + allMarked := len(items) > 0 + for _, raw := range items { + if !m.marked[raw.(fileItem).path] { + allMarked = false + break + } + } + for _, raw := range items { + p := raw.(fileItem).path + if allMarked { + delete(m.marked, p) + } else { + m.marked[p] = true + } + } +} + +// requestDelete gathers the deletion targets — the marked files, or the file +// under the cursor when nothing is marked — and raises the confirm card. +func (m Model) requestDelete() (tea.Model, tea.Cmd) { + var targets []string + for _, raw := range m.fileList.Items() { + it := raw.(fileItem) + if m.marked[it.path] { + targets = append(targets, it.path) + } + } + if len(targets) == 0 { + if it, ok := m.fileList.SelectedItem().(fileItem); ok { + targets = append(targets, it.path) + } + } + if len(targets) == 0 { + return m, nil + } + m.delTargets = targets + m.confirmDel = true + return m, nil +} + +// updateConfirmDelete handles the delete-confirmation card. +func (m Model) updateConfirmDelete(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y", "enter": + m.doDelete() + m.confirmDel = false + m.delTargets = nil + case "n", "N", "esc", "ctrl+c": + m.confirmDel = false + m.delTargets = nil + } + return m, nil +} + +// doDelete removes the queued targets from disk, then reloads the list. As a +// guard against deleting anything outside the receive folder, only direct +// children of it are removed. +func (m *Model) doDelete() { + recv := filepath.Clean(expandHome(m.cfg.ReceiveDir)) + var failed []string + for _, p := range m.delTargets { + if filepath.Dir(p) != recv { + failed = append(failed, filepath.Base(p)) + continue + } + if err := os.RemoveAll(p); err != nil { + failed = append(failed, filepath.Base(p)) + continue + } + delete(m.marked, p) + } + m.refreshManage() + if len(failed) > 0 { + m.manageErr = "failed to delete: " + strings.Join(failed, ", ") + } +} + +// clearFinished drops terminal transfers from the list. +func (m *Model) clearFinished() { + kept := m.transfers[:0] + for _, x := range m.transfers { + if x.finished() { + delete(m.xferIndex, x.key) + } else { + kept = append(kept, x) + } + } + m.transfers = kept +} + +func (m Model) settingsView() string { + if m.editing { + return m.settingsEditView() + } + var b strings.Builder + rows := [][2]string{ + {"Alias", m.cfg.Alias}, + {"Device type", m.cfg.DeviceType}, + {"Protocol", m.cfg.Protocol}, + {"Fingerprint", m.cfg.Fingerprint}, + {"Port", fmt.Sprintf("%d", m.cfg.Port)}, + {"Receive dir", m.cfg.ReceiveDir}, + {"Auto-accept", boolStr(m.autoAccept)}, + {"PIN", boolStr(m.cfg.PIN != "")}, + {"Icons", boolStr(!m.cfg.NoIcons)}, + {"Local IPs", strings.Join(m.ips, ", ")}, + } + for _, r := range rows { + b.WriteString(labelStyle.Render(r[0])) + b.WriteString(valueStyle.Render(r[1])) + b.WriteByte('\n') + } + return b.String() +} + +// settingsEditView renders the editable settings form. +func (m Model) settingsEditView() string { + labels := []string{"Alias", "Receive dir", "PIN"} + var b strings.Builder + b.WriteString(titleStyle.Render("Edit settings")) + b.WriteString("\n\n") + for i, ti := range m.editInputs { + marker := " " + if i == m.editFocus { + marker = "> " + } + b.WriteString(marker + labelStyle.Render(labels[i]) + ti.View() + "\n") + } + return b.String() +} + +// footerText is the contextual help line shown at the bottom of the window. +func (m Model) footerText() string { + switch { + case m.confirmDel: + return "y/enter delete · n/esc cancel" + 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" + case m.screen == screenPeers: + return "enter send-to · r refresh · / filter · 1-4 switch · q quit" + case m.screen == screenTransfers: + return "c clear finished · 1-4 switch · q quit" + case m.screen == screenManage: + return "space mark · a all · d delete · r refresh · / filter · 1-4 switch · q quit" + case m.screen == screenSettings: + return "e edit · a auto-accept · i icons · 1-4 switch · q quit" + } + return "q quit" +} + +func boolStr(v bool) string { + if v { + return "on" + } + return "off" +} + +func contains(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} + +// collapseHome shortens an absolute path under $HOME to a ~-prefixed form. +func collapseHome(p string) string { + if home, err := os.UserHomeDir(); err == nil && home != "" && strings.HasPrefix(p, home) { + return "~" + p[len(home):] + } + return p +} + +// expandHome resolves a leading ~ (or ~/) to the user's home directory. It is +// the inverse of collapseHome and tolerates the ~-form a user may type into the +// receive-dir setting. +func expandHome(p string) string { + if p == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + return p + } + if strings.HasPrefix(p, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, p[2:]) + } + } + return p +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + if n <= 1 { + return s[:n] + } + return s[:n-1] + "…" +} + +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go new file mode 100644 index 0000000..5e4595b --- /dev/null +++ b/internal/tui/render_test.go @@ -0,0 +1,63 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "omarchy-send/internal/app" + "omarchy-send/internal/config" + "omarchy-send/internal/discovery" + "omarchy-send/internal/protocol" + "omarchy-send/internal/transfer" +) + +func testModel(t *testing.T) Model { + t.Helper() + cfg := config.Config{ + Alias: "omarchy", Port: 53317, ReceiveDir: "~/Omarchy-Send", + DeviceType: "server", Protocol: "https", + Fingerprint: "E24D7564CC4FFAB7337FB68BC1CE6284F524D1CEAC8FA24FEDB8130BCD2068AB", + } + m := New(cfg, nil) + m.ips = []string{"192.168.1.97"} + nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + return nm.(Model) +} + +func feedPeer(m Model, alias, ip string, dt protocol.DeviceType) Model { + peer := discovery.Peer{Info: protocol.DeviceInfo{Alias: alias, DeviceType: dt, Port: 53317, Fingerprint: alias}, IP: ip} + nm, _ := m.Update(app.PeerFoundMsg{Peer: peer}) + return nm.(Model) +} + +// TestRenderDump prints each screen at 100x30 for visual inspection (go test +// -run RenderDump -v) and asserts the frame chrome is present. +func TestRenderDump(t *testing.T) { + m := testModel(t) + m = feedPeer(m, "iPhone", "192.168.1.34", protocol.DeviceMobile) + m = feedPeer(m, "MacBook", "192.168.1.43", protocol.DeviceDesktop) + + out := m.View() + for _, want := range []string{"Omarchy-Send", "Devices", "Transfers", "Settings", "192.168.1.97"} { + if !strings.Contains(out, want) { + t.Errorf("Devices view missing %q", want) + } + } + fmt.Println("\n===================== PEERS =====================") + fmt.Println(out) + + mt, _ := m.Update(app.TransferMsg{Ev: transfer.Event{Dir: transfer.Incoming, Kind: transfer.Progress, ID: "s:1", FileName: "IMG_0480.DNG", Received: 1_200_000, Total: 4_455_339}}) + m = mt.(Model) + mt, _ = m.Update(app.TransferMsg{Ev: transfer.Event{Dir: transfer.Outgoing, Kind: transfer.FileDone, ID: "s:2", FileName: "holiday.mp4", Received: 8_000_000, Total: 8_000_000}}) + m = mt.(Model) + m.screen = screenTransfers + fmt.Println("\n=================== TRANSFERS ===================") + fmt.Println(m.View()) + + m.screen = screenSettings + fmt.Println("\n=================== SETTINGS ====================") + fmt.Println(m.View()) +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..7eb6497 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,65 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" + + "omarchy-send/internal/theme" +) + +// Colours and styles, (re)built from the active Omarchy theme by applyTheme. +// Colours are truecolor hex from the theme; lipgloss downsamples as needed and +// honours NO_COLOR. +var ( + accent lipgloss.Color + text lipgloss.Color + dim lipgloss.Color + muted lipgloss.Color + good lipgloss.Color + bad lipgloss.Color + + titleBarStyle lipgloss.Style + tabActiveStyle lipgloss.Style + tabInactiveStyle lipgloss.Style + frameStyle lipgloss.Style + cardStyle lipgloss.Style + footerStyle lipgloss.Style + titleStyle lipgloss.Style + headerStyle lipgloss.Style + labelStyle lipgloss.Style + valueStyle lipgloss.Style +) + +func init() { applyTheme(theme.Default()) } + +// applyTheme rebuilds all styles from t. Called once at startup with the active +// Omarchy theme; init() seeds defaults for tests. +func applyTheme(t theme.Theme) { + accent = lipgloss.Color(t.Accent) + text = lipgloss.Color(t.Fg) + bg := lipgloss.Color(t.Bg) + dim = lipgloss.Color(t.Dim) + muted = lipgloss.Color(t.Muted) + good = lipgloss.Color(t.Good) + bad = lipgloss.Color(t.Bad) + + // Title bar & active tab: theme background text on the accent (high contrast + // regardless of whether the accent is light or dark). + titleBarStyle = lipgloss.NewStyle().Bold(true).Foreground(bg).Background(accent) + tabActiveStyle = lipgloss.NewStyle().Bold(true).Foreground(bg).Background(accent).Padding(0, 2) + tabInactiveStyle = lipgloss.NewStyle().Foreground(dim).Padding(0, 2) + + frameStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(accent). + Padding(0, 1) + cardStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(accent). + Padding(1, 2) + + footerStyle = lipgloss.NewStyle().Foreground(muted).Padding(0, 1) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent) + headerStyle = lipgloss.NewStyle().Foreground(dim) + labelStyle = lipgloss.NewStyle().Foreground(dim).Width(14) + valueStyle = lipgloss.NewStyle().Foreground(text) +} diff --git a/internal/tui/view_manage.go b/internal/tui/view_manage.go new file mode 100644 index 0000000..4d7cd2f --- /dev/null +++ b/internal/tui/view_manage.go @@ -0,0 +1,167 @@ +package tui + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// fileItem adapts an entry in the receive directory to bubbles/list.Item. +type fileItem struct { + path string + name string + size int64 + modTime time.Time + isDir bool +} + +func (i fileItem) Title() string { return i.name } +func (i fileItem) Description() string { return "" } +func (i fileItem) FilterValue() string { return i.name } + +// receivedFiles lists the top-level entries in dir, newest first. In-progress +// transfers (".part" temp files) are skipped so the manage view never offers to +// delete a file that is still being written. A missing directory yields an empty +// slice and no error — nothing has been received yet. +func receivedFiles(dir string) ([]list.Item, error) { + dir = expandHome(dir) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + files := make([]fileItem, 0, len(entries)) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".part") { + continue // a transfer in flight; renamed into place on success + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, fileItem{ + path: filepath.Join(dir, e.Name()), + name: e.Name(), + size: info.Size(), + modTime: info.ModTime(), + isDir: e.IsDir(), + }) + } + sort.Slice(files, func(i, j int) bool { return files[i].modTime.After(files[j].modTime) }) + items := make([]list.Item, len(files)) + for i, f := range files { + items[i] = f + } + return items, nil +} + +// Manage list column widths. +const ( + colFileName = 36 + colFileSize = 12 +) + +// fileHeader is the dim column-header row shown above the received-files list. +// The leading pad covers the cursor bar (2) + mark column (2). +func fileHeader() string { + h := lipgloss.NewStyle().Foreground(muted) + return " " + + h.Width(colFileName).Render("Name") + + h.Width(colFileSize).Render("Size") + + h.Render("Received") +} + +// fileDelegate renders each received file as one aligned table row: +// +// ▌ ✓ +// +// It shares the Model's marked set by reference so toggles show immediately. +type fileDelegate struct{ marked map[string]bool } + +func (fileDelegate) Height() int { return 1 } +func (fileDelegate) Spacing() int { return 0 } +func (fileDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil } + +func (d fileDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + it, ok := item.(fileItem) + if !ok { + return + } + name := it.name + if it.isDir { + name += "/" + } + name = truncate(name, colFileName-2) + + check := " " + if d.marked[it.path] { + check = lipgloss.NewStyle().Foreground(good).Render("✓ ") + } + size := "—" + if !it.isDir { + size = humanBytes(it.size) + } + date := it.modTime.Format("2006-01-02 15:04") + + nameSt := lipgloss.NewStyle().Width(colFileName) + sizeSt := lipgloss.NewStyle().Width(colFileSize) + + if index == m.Index() { + fmt.Fprint(w, lipgloss.NewStyle().Foreground(accent).Render("▌ ")+check+ + nameSt.Foreground(accent).Bold(true).Render(name)+ + sizeSt.Foreground(accent).Render(size)+ + lipgloss.NewStyle().Foreground(accent).Render(date)) + return + } + fmt.Fprint(w, " "+check+ + nameSt.Foreground(text).Render(name)+ + sizeSt.Foreground(dim).Render(size)+ + lipgloss.NewStyle().Foreground(muted).Render(date)) +} + +// manageView renders the received-files list plus a one-line status (delete +// error, or count marked) on the spare row beneath it. +func (m Model) manageView() string { + var b strings.Builder + b.WriteString(fileHeader()) + b.WriteString("\n") + b.WriteString(m.fileList.View()) + switch { + case m.manageErr != "": + b.WriteString("\n" + lipgloss.NewStyle().Foreground(bad).Render(m.manageErr)) + case len(m.marked) > 0: + b.WriteString("\n" + titleStyle.Render(fmt.Sprintf("%d marked for deletion", len(m.marked)))) + } + return b.String() +} + +// confirmDeleteView is the centered card shown before files are removed. +func (m Model) confirmDeleteView() string { + const maxShow = 8 + var b strings.Builder + b.WriteString(titleStyle.Render("Delete files")) + b.WriteString("\n\n") + b.WriteString(headerStyle.Render(fmt.Sprintf("Permanently delete %d item(s) from the receive folder?", len(m.delTargets)))) + b.WriteString("\n\n") + for i, p := range m.delTargets { + if i == maxShow { + b.WriteString(headerStyle.Render(fmt.Sprintf(" … and %d more", len(m.delTargets)-maxShow))) + b.WriteByte('\n') + break + } + b.WriteString(" • " + filepath.Base(p) + "\n") + } + b.WriteString("\n") + b.WriteString(footerStyle.Render("y/enter delete · n/esc cancel")) + return b.String() +} diff --git a/internal/tui/view_peers.go b/internal/tui/view_peers.go new file mode 100644 index 0000000..b1db654 --- /dev/null +++ b/internal/tui/view_peers.go @@ -0,0 +1,90 @@ +package tui + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "omarchy-send/internal/discovery" + "omarchy-send/internal/protocol" +) + +// peerItem adapts a discovered peer to bubbles/list.Item. +type peerItem struct{ p discovery.Peer } + +func (i peerItem) Title() string { return i.p.Info.Alias } +func (i peerItem) Description() string { return string(i.p.Info.DeviceType) } +func (i peerItem) FilterValue() string { return i.p.Info.Alias } + +// deviceIcon returns a Nerd Font glyph for a device type (the Omarchy terminal +// ships a Nerd Font; degrades to a box glyph elsewhere, which is cosmetic only). +func deviceIcon(dt protocol.DeviceType) string { + switch dt { + case protocol.DeviceMobile: + return "" // phone + case protocol.DeviceDesktop: + return "" // desktop + case protocol.DeviceServer, protocol.DeviceHeadless: + return "" // server + case protocol.DeviceWeb: + return "" // globe + default: + return "" // generic display + } +} + +// Device list column widths (impala-style table). +const ( + colName = 30 + colType = 14 +) + +// deviceHeader is the dim column-header row shown above the device list. +func deviceHeader() string { + h := lipgloss.NewStyle().Foreground(muted) + return " " + + h.Width(colName).Render("Name") + + h.Width(colType).Render("Type") + + h.Render("Address") +} + +// deviceDelegate renders each device as one aligned table row, themed to the +// active Omarchy palette: ▌ alias type ip +// icons is false on terminals without a Nerd Font (the glyphs are dropped). +type deviceDelegate struct{ icons bool } + +func (deviceDelegate) Height() int { return 1 } +func (deviceDelegate) Spacing() int { return 0 } +func (deviceDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil } + +func (d deviceDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + it, ok := item.(peerItem) + if !ok { + return + } + dt := string(it.p.Info.DeviceType) + if dt == "" { + dt = "device" + } + name := truncate(it.p.Info.Alias, colName-2) + if d.icons { + name = fmt.Sprintf("%s %s", deviceIcon(it.p.Info.DeviceType), truncate(it.p.Info.Alias, colName-4)) + } + + nameSt := lipgloss.NewStyle().Width(colName) + typeSt := lipgloss.NewStyle().Width(colType) + if index == m.Index() { + fmt.Fprint(w, lipgloss.NewStyle().Foreground(accent).Render("▌ ")+ + nameSt.Foreground(accent).Bold(true).Render(name)+ + typeSt.Foreground(accent).Render(dt)+ + lipgloss.NewStyle().Foreground(accent).Render(it.p.IP)) + return + } + fmt.Fprint(w, " "+ + nameSt.Foreground(text).Render(name)+ + typeSt.Foreground(dim).Render(dt)+ + lipgloss.NewStyle().Foreground(muted).Render(it.p.IP)) +}