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) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-05-27 19:26:08 +01:00
commit 2dd81700c0
37 changed files with 4139 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/omarchy-send
/dist/omarchy-send-linux-*
*.tmp

21
LICENSE Normal file
View file

@ -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.

111
README.md Normal file
View file

@ -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).

124
cmd/omarchy-send/main.go Normal file
View file

@ -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)
}
}

6
dist/omarchy-send.svg vendored Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<rect width="256" height="256" rx="56" fill="#16161e"/>
<!-- paper-plane: upper wing (light) + lower flap (darker) -->
<path d="M214 42 L42 114 L110 142 Z" fill="#7aa2f7"/>
<path d="M214 42 L110 142 L130 214 L158 166 Z" fill="#5a7fd6"/>
</svg>

After

Width:  |  Height:  |  Size: 340 B

36
go.mod Normal file
View file

@ -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
)

64
go.sum Normal file
View file

@ -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=

121
install.sh Executable file
View file

@ -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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<rect width="256" height="256" rx="56" fill="#16161e"/>
<!-- paper-plane: upper wing (light) + lower flap (darker) -->
<path d="M214 42 L42 114 L110 142 Z" fill="#7aa2f7"/>
<path d="M214 42 L110 142 L130 214 L158 166 Z" fill="#5a7fd6"/>
</svg>
SVG
gtk-update-icon-cache -q -t -f "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
cat > "$APP_DIR/omarchy-send.desktop" <<EOF
[Desktop Entry]
Name=Omarchy-Send
Comment=Send & receive files over the LAN (LocalSend-compatible)
Exec=xdg-terminal-exec --app-id=TUI.float -e $BIN
Icon=omarchy-send
Terminal=false
Type=Application
Categories=Network;FileTransfer;
Keywords=localsend;share;transfer;airdrop;
EOF
echo "==> 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"

79
internal/app/events.go Normal file
View file

@ -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})
}
}
}
}()
}

238
internal/client/client.go Normal file
View file

@ -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)
}

View file

@ -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))
}
}

View file

@ -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
}

163
internal/config/config.go Normal file
View file

@ -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))
}

39
internal/dbg/dbg.go Normal file
View file

@ -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...)...)
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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"
)

View file

@ -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
}

View file

@ -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"`
}

68
internal/security/cert.go Normal file
View file

@ -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
}

18
internal/server/events.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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()
}

349
internal/server/server.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

118
internal/server/session.go Normal file
View file

@ -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)
}

View file

@ -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)
}
}

97
internal/theme/theme.go Normal file
View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

112
internal/tui/manage_test.go Normal file
View file

@ -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")
}
}

1040
internal/tui/model.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -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())
}

65
internal/tui/styles.go Normal file
View file

@ -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)
}

167
internal/tui/view_manage.go Normal file
View file

@ -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:
//
// ▌ ✓ <name> <size> <received-at>
//
// 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()
}

View file

@ -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: ▌ <icon> 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))
}