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:
commit
2dd81700c0
37 changed files with 4139 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/omarchy-send
|
||||||
|
/dist/omarchy-send-linux-*
|
||||||
|
*.tmp
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
111
README.md
Normal 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
124
cmd/omarchy-send/main.go
Normal 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
6
dist/omarchy-send.svg
vendored
Normal 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
36
go.mod
Normal 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
64
go.sum
Normal 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
121
install.sh
Executable 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
79
internal/app/events.go
Normal 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
238
internal/client/client.go
Normal 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)
|
||||||
|
}
|
||||||
73
internal/client/client_test.go
Normal file
73
internal/client/client_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/client/progress.go
Normal file
28
internal/client/progress.go
Normal 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
163
internal/config/config.go
Normal 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
39
internal/dbg/dbg.go
Normal 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...)...)
|
||||||
|
}
|
||||||
286
internal/discovery/discovery.go
Normal file
286
internal/discovery/discovery.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
102
internal/discovery/discovery_test.go
Normal file
102
internal/discovery/discovery_test.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
internal/protocol/consts.go
Normal file
20
internal/protocol/consts.go
Normal 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"
|
||||||
|
)
|
||||||
37
internal/protocol/device.go
Normal file
37
internal/protocol/device.go
Normal 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
|
||||||
|
}
|
||||||
34
internal/protocol/files.go
Normal file
34
internal/protocol/files.go
Normal 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
68
internal/security/cert.go
Normal 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
18
internal/server/events.go
Normal 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
|
||||||
|
}
|
||||||
56
internal/server/pin_test.go
Normal file
56
internal/server/pin_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
internal/server/progress.go
Normal file
36
internal/server/progress.go
Normal 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
|
||||||
|
}
|
||||||
87
internal/server/receive_test.go
Normal file
87
internal/server/receive_test.go
Normal 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
349
internal/server/server.go
Normal 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
|
||||||
|
}
|
||||||
52
internal/server/server_test.go
Normal file
52
internal/server/server_test.go
Normal 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
118
internal/server/session.go
Normal 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)
|
||||||
|
}
|
||||||
65
internal/server/tls_test.go
Normal file
65
internal/server/tls_test.go
Normal 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
97
internal/theme/theme.go
Normal 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
|
||||||
|
}
|
||||||
30
internal/theme/theme_test.go
Normal file
30
internal/theme/theme_test.go
Normal 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)
|
||||||
|
}
|
||||||
41
internal/transfer/transfer.go
Normal file
41
internal/transfer/transfer.go
Normal 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
112
internal/tui/manage_test.go
Normal 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
1040
internal/tui/model.go
Normal file
File diff suppressed because it is too large
Load diff
63
internal/tui/render_test.go
Normal file
63
internal/tui/render_test.go
Normal 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
65
internal/tui/styles.go
Normal 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
167
internal/tui/view_manage.go
Normal 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()
|
||||||
|
}
|
||||||
90
internal/tui/view_peers.go
Normal file
90
internal/tui/view_peers.go
Normal 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))
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue