From 2c058031fd804da06ff5311ba3cad1f58dfa6c63 Mon Sep 17 00:00:00 2001 From: 28allday Date: Tue, 2 Jun 2026 21:47:52 +0100 Subject: [PATCH] Add remote-peer support over Tailscale and unicast probing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multicast only finds peers on the same LAN. Reach off-LAN boxes by probing them directly over unicast (works over any routable address; Tailscale is the easy, secure choice): - discovery.Probe: unicast POST /register (https->http fallback) with a two-way handshake, so send and receive both work; offline peers age out. - internal/tailscale: Peers() shells `tailscale status --json` for online peers. - config.KnownPeers: persisted manual remotes. - main.go: watchRemotes goroutine probes knownPeers ∪ tailscale peers every 10s, in both the normal TUI path and quick-send. - TUI: `+` on Devices opens an add-remote modal (host/IP/Tailscale name). - install.sh: interactive local/remote install prompt; remote mode locks port 53317 to the Tailscale interface (ufw), with container/userspace-networking detection. README documents remote devices. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 40 +++++++++-- cmd/omarchy-send/main.go | 89 ++++++++++++++++++++++- install.sh | 102 ++++++++++++++++++++++++++- internal/config/config.go | 5 ++ internal/discovery/discovery.go | 55 +++++++++++++++ internal/discovery/probe_test.go | 64 +++++++++++++++++ internal/tailscale/tailscale.go | 77 ++++++++++++++++++++ internal/tailscale/tailscale_test.go | 46 ++++++++++++ internal/tui/manage_test.go | 6 +- internal/tui/model.go | 74 ++++++++++++++++++- internal/tui/send_test.go | 1 + internal/tui/view_manage.go | 2 +- 12 files changed, 547 insertions(+), 14 deletions(-) create mode 100644 internal/discovery/probe_test.go create mode 100644 internal/tailscale/tailscale.go create mode 100644 internal/tailscale/tailscale_test.go diff --git a/README.md b/README.md index e2725f7..c70c7b2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ LocalSend mobile and desktop apps on the same LAN, including their default - **Discovery** — multicast announce/listen on `224.0.0.167:53317` plus the HTTP `/register` handshake, with peer aging. +- **Remote devices** — reach boxes that aren't on your LAN (multicast can't find + them) by probing them directly over unicast. If [Tailscale](https://tailscale.com) + is running, online tailnet peers are discovered automatically; you can also add + a device by host/IP/name with the `+` key, saved for next time. - **Receive** — incoming files are accepted via a prompt (or auto-accepted) and written to the receive directory, with live progress. - **Send** — pick a peer, then find what to send with a built-in **recursive @@ -47,9 +51,16 @@ curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install. ``` 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`. +Omarchy also adds a floating Walker entry (search **Omarchy-Send**) and the +Nautilus right-click integration. Override the location with `BIN_DIR=/usr/local/bin`, +or pin a version with `OMARCHY_SEND_VERSION=v0.1.0`. + +**Local or remote?** When run interactively the installer asks whether this is a +**local** machine (home/LAN) or a **remote server** (public IP). Local installs as +above. For a remote server it additionally locks port `53317` to the Tailscale +interface in the firewall (`ufw`), so the box is reachable over your tailnet only — +not the open internet. Non-interactive installs (e.g. piped `curl | bash`) default +to local; force a choice with `OMARCHY_SEND_MODE=local` or `OMARCHY_SEND_MODE=remote`. > 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. @@ -111,6 +122,27 @@ Staging a folder sends it whole (its structure is recreated on the receiver). Matching is case-insensitive, and noisy directories (`.git`, `node_modules`, caches, dotfiles…) are skipped to keep the index fast. +### Remote devices (over Tailscale) + +Multicast discovery only finds peers on the same LAN. To send to / receive from a +box elsewhere, omarchy-send probes it directly over unicast — which works over +anything routable, [Tailscale](https://tailscale.com) being the easy, secure choice +(stable addresses, end-to-end encryption, no port-forwarding): + +1. `tailscale up` on both devices (one-time). +2. Either let omarchy-send **auto-discover** online tailnet peers (it probes them + every few seconds; any running omarchy-send/LocalSend appears in Devices), or + press **`+`** on the Devices tab and enter a host, IP, or Tailscale name (e.g. + `colossus`). Added devices are saved to `knownPeers` in the config and re-probed + on every launch. + +The receiver already listens on all interfaces, so it's reachable at its Tailscale +IP with nothing else to configure. Sending and receiving both work, because the +probe is a two-way handshake (each side learns the other). + +> On a box with a public IP, don't leave `53317` open to the internet — install in +> **remote** mode (above) to firewall it to the tailnet, and/or set a `--pin`. + ### Right-click send (Nautilus) On an Omarchy desktop, the installer adds a **"Send via Omarchy-Send"** entry to @@ -154,7 +186,7 @@ omarchy-send --auto-accept --pin 2468 ### Keys - `1`–`5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings -- Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `r` refresh · `/` filter +- Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `+` add a remote device · `r` refresh · `/` filter - PIN-protected peers: messages prompt for the PIN and retry, just like file sends - Send finder: type to fuzzy-filter · `enter` stage file/folder · `ctrl+d` folders-only · `ctrl+s` send · `ctrl+u` up a dir · `esc` back - Incoming prompt: `y` accept · `n` reject diff --git a/cmd/omarchy-send/main.go b/cmd/omarchy-send/main.go index 18e89e1..84f6852 100644 --- a/cmd/omarchy-send/main.go +++ b/cmd/omarchy-send/main.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "time" @@ -24,6 +25,7 @@ import ( "omarchy-send/internal/discovery" "omarchy-send/internal/notify" "omarchy-send/internal/server" + "omarchy-send/internal/tailscale" "omarchy-send/internal/transfer" "omarchy-send/internal/tui" ) @@ -34,6 +36,85 @@ type controller struct { sender *client.Sender srv *server.Server notify *atomic.Bool // live gate for desktop notifications (toggled from Settings) + rem *remotes // live set of directly-probed (known/remote) hosts +} + +// remotes is the live set of hosts probed directly over unicast: known peers +// loaded from config plus any added at runtime in the TUI. Guarded because the +// watcher goroutine and the controller's AddKnownPeer both touch it. +type remotes struct { + mu sync.Mutex + hosts []string +} + +func (r *remotes) list() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.hosts...) +} + +// add appends host if not already present, returning true if it was new. +func (r *remotes) add(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + r.mu.Lock() + defer r.mu.Unlock() + for _, h := range r.hosts { + if h == host { + return false + } + } + r.hosts = append(r.hosts, host) + return true +} + +// AddKnownPeer registers a remote host and probes it immediately so it shows up +// without waiting for the next watcher tick. Persisting it to config is the +// TUI's job; this only updates the live set. +func (c controller) AddKnownPeer(host string) { + if c.rem != nil { + c.rem.add(host) + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + _ = c.disc.Probe(ctx, host) + }() +} + +// watchRemotes periodically probes the known-peer set plus any online Tailscale +// peers, so devices that multicast can't reach (different subnet / over the +// tailnet) still appear in the list — and age out when they stop answering. +func watchRemotes(ctx context.Context, disc *discovery.Discoverer, rem *remotes) { + probeAll := func() { + seen := map[string]bool{} + hosts := rem.list() + hosts = append(hosts, tailscale.Peers(ctx)...) + for _, h := range hosts { + if h == "" || seen[h] { + continue + } + seen[h] = true + go func(host string) { + pctx, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + _ = disc.Probe(pctx, host) + }(h) + } + } + probeAll() // immediate, so remotes appear without waiting a tick + t := time.NewTicker(10 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + probeAll() + } + } } func (c controller) Announce() { c.disc.Announce() } @@ -186,7 +267,9 @@ func main() { // only carries the user preference here. notifyOn := &atomic.Bool{} notifyOn.Store(!cfg.NoNotify) - ctrl := controller{disc: disc, sender: sender, srv: srv, notify: notifyOn} + rem := &remotes{hosts: cfg.KnownPeers} + ctrl := controller{disc: disc, sender: sender, srv: srv, notify: notifyOn, rem: rem} + go watchRemotes(ctx, disc, rem) p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen()) app.BridgeDiscovery(ctx, disc.Events(), p.Send) @@ -222,7 +305,9 @@ func runQuickSend(cfg config.Config, paths []string) int { // No receiver in quick-send mode, so nothing to notify about. notifyOff := &atomic.Bool{} - ctrl := controller{disc: disc, sender: sender, srv: nil, notify: notifyOff} + rem := &remotes{hosts: cfg.KnownPeers} + ctrl := controller{disc: disc, sender: sender, srv: nil, notify: notifyOff, rem: rem} + go watchRemotes(ctx, disc, rem) // so a remote box is a valid quick-send target too p := tea.NewProgram(tui.New(cfg, ctrl, tui.WithStagedFiles(paths)), tea.WithAltScreen()) app.BridgeDiscovery(ctx, disc.Events(), p.Send) diff --git a/install.sh b/install.sh index ba8519c..8b215cb 100755 --- a/install.sh +++ b/install.sh @@ -14,11 +14,15 @@ # Environment overrides: # BIN_DIR=/usr/local/bin install location (default ~/.local/bin) # OMARCHY_SEND_VERSION=v0.1.0 pin a release (default: latest) +# OMARCHY_SEND_MODE=local|remote skip the local/remote prompt (default: ask, +# or local when non-interactive) # # 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. +# - Local machine (home/LAN): installs the TUI; on Omarchy also adds a Walker +# entry + the Nautilus right-click integration. +# - Remote server (public IP): same install, then restricts port 53317 to the +# Tailscale interface in the firewall, so it's reachable over the tailnet +# only — not the open internet. set -euo pipefail @@ -27,6 +31,7 @@ BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" APP_DIR="$HOME/.local/share/applications" BIN="$BIN_DIR/omarchy-send" VERSION="${OMARCHY_SEND_VERSION:-latest}" +PORT=53317 mkdir -p "$BIN_DIR" @@ -36,6 +41,30 @@ if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" fi +# ---- local vs remote ----------------------------------------------------- +# A remote (public-IP) server should not expose the transfer port to the +# internet. Ask once; default to "local" when non-interactive (e.g. piped +# `curl | bash` with no terminal) so a firewall is never changed without intent. +# Reads /dev/tty so the prompt still works under curl|bash. +MODE="${OMARCHY_SEND_MODE:-}" +case "$MODE" in + local | remote) : ;; # explicit override, don't ask + *) + MODE="local" + # Try to open the controlling terminal read-write on fd 3. A bare -r test + # isn't enough: /dev/tty can exist yet fail to open (no controlling tty — + # cron/CI/piped). Only prompt when the open actually succeeds. + if { exec 3<>/dev/tty; } 2>/dev/null; then + printf 'Install type — [L]ocal machine (home/LAN) or [r]emote server (public IP)? [L/r] ' >&3 || true + IFS= read -r _ans <&3 || _ans="" + exec 3>&- 3<&- || true + case "$_ans" in + r | R | remote | Remote | REMOTE) MODE="remote" ;; + esac + fi + ;; +esac + # ---- 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..." @@ -227,6 +256,73 @@ else echo "==> Headless system — installed as a plain TUI." fi +# ---- firewall posture ---------------------------------------------------- +# Shared by the remote-mode lockdown below and the local-mode public-IP warning. +# +# Tailscale interface: usually tailscale0, but absent when tailscaled runs in +# userspace-networking mode (the default inside containers) — don't hardcode it. +TS_IFACE="$(ip -o link show 2>/dev/null | grep -oE 'tailscale[0-9]+' | head -n1)" + +# Container? Under Docker host-networking the port binds the *host's* stack, and +# the firewall belongs on the host, not in this namespace. +IN_CONTAINER=0 +if [ -f /.dockerenv ] || grep -qaE 'docker|containerd|kubepods' /proc/1/cgroup 2>/dev/null; then + IN_CONTAINER=1 +fi + +# ---- remote server: restrict the port to the Tailscale network ----------- +# On a public-IP box, port 53317 would otherwise be reachable from the internet +# (the receiver binds all interfaces). Lock it to the Tailscale interface so it +# only answers over the tailnet. Multicast LAN discovery is link-local and never +# routes off-LAN, so nothing else needs opening. Inside a container the firewall +# can't be applied from here — userspace-networking has no tailscale0 and host- +# networking puts the bind on the host's stack — so we detect that and say so. +if [ "$MODE" = "remote" ]; then + echo "==> Remote server — restricting port $PORT to the Tailscale network." + + if [ "$IN_CONTAINER" = "1" ] && [ -z "$TS_IFACE" ]; then + # Container + userspace-networking Tailscale: no tailscale0, and typically no + # CAP_NET_ADMIN to manage netfilter. A firewall can't be applied from in here. + echo " Detected: inside a container with userspace-networking Tailscale" + echo " (no tailscale0 interface). The receiver binds all interfaces — and under" + echo " Docker host-networking that includes the host's PUBLIC interface." + echo + echo " A firewall CANNOT be applied from in here. Apply it on the HOST:" + echo " • if the host already default-denies inbound (e.g. only 22/80/443 open)," + echo " $PORT is already blocked from the internet yet still reachable over the" + echo " tailnet (tailscaled delivers it via loopback) — nothing more to do." + echo " • otherwise, on the host run: ufw deny $PORT" + echo " Strongly recommended in this setup: also set a PIN (--pin )." + elif [ -n "$TS_IFACE" ] && command -v ufw >/dev/null 2>&1; then + SUDO="" + [ "$(id -u)" -ne 0 ] && SUDO="sudo" + echo " Tailscale interface: $TS_IFACE" + echo " Applying firewall rules (may prompt for sudo):" + echo " ${SUDO:+$SUDO }ufw allow in on $TS_IFACE to any port $PORT" + echo " ${SUDO:+$SUDO }ufw deny $PORT" + if $SUDO ufw allow in on "$TS_IFACE" to any port "$PORT" >/dev/null 2>&1 && + $SUDO ufw deny "$PORT" >/dev/null 2>&1; then + echo " Done — $PORT answers over Tailscale only." + else + echo " Could not apply automatically (need root/sudo). Run the two commands above yourself." + fi + else + if [ -z "$TS_IFACE" ]; then + echo " NOTE: no tailscale interface found. If tailscale isn't up yet, install it" + echo " and run 'tailscale up', then re-run this installer. If it's running" + echo " in userspace-networking mode, firewall the port on the host instead." + TS_IFACE="tailscale0" + fi + echo " ufw not found. Apply the equivalent in your firewall:" + echo " • allow inbound TCP $PORT only on the '$TS_IFACE' interface" + echo " • deny inbound $PORT on all other interfaces" + echo " nftables example (inet filter, input chain):" + echo " iifname \"$TS_IFACE\" tcp dport $PORT accept" + echo " tcp dport $PORT drop" + fi + echo " Tip: a PIN adds a second layer — run with --pin (or set it in Settings)." +fi + echo case ":$PATH:" in *":$BIN_DIR:"*) : ;; diff --git a/internal/config/config.go b/internal/config/config.go index 5ec3378..34c5518 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,11 @@ type Config struct { NoIcons bool `json:"noIcons"` // hide Nerd Font device icons (non-NF terminals) NoNotify bool `json:"noNotify"` // don't raise desktop notifications on incoming messages/files + // KnownPeers are hosts (name, IP, or host:port) probed directly over unicast + // so peers off the local subnet — e.g. reached over Tailscale — show up even + // though multicast discovery can't find them. + KnownPeers []string `json:"knownPeers,omitempty"` + // TLS identity for encrypted (HTTPS) mode, generated once and persisted. CertPEM string `json:"certPem"` KeyPEM string `json:"keyPem"` diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index 707b5a5..b419ca4 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -295,6 +295,61 @@ func (d *Discoverer) reply(ip string, port int, proto string) { _ = resp.Body.Close() } +// Probe contacts host directly over unicast — bypassing multicast — and records +// it as a peer on success. It POSTs our info to the peer's /register (so the +// peer also learns us) and reads the peer's info from the reply. host may carry +// a port; otherwise the default LocalSend port is used. https is tried first, +// then http. Used for known/remote peers (e.g. reached over Tailscale) that +// multicast can't find. Re-probing a live peer refreshes its LastSeen so it is +// not reaped; a peer that stops answering ages out normally. +func (d *Discoverer) Probe(ctx context.Context, host string) error { + h, port := hostPort(host) + body, err := json.Marshal(d.selfCopy().WithAnnounce(false)) + if err != nil { + return err + } + var lastErr error + for _, scheme := range []string{"https", "http"} { + url := fmt.Sprintf("%s://%s/api/localsend/v2/register", scheme, net.JoinHostPort(h, strconv.Itoa(port))) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := d.client.Do(req) + if err != nil { + lastErr = err + continue + } + var info protocol.DeviceInfo + derr := json.NewDecoder(resp.Body).Decode(&info) + _ = resp.Body.Close() + if derr != nil || info.Fingerprint == "" { + lastErr = fmt.Errorf("probe %s: no usable device info", url) + continue + } + dbg.Logf("probe %s -> alias=%q fp=%s", url, info.Alias, info.Fingerprint) + d.NotePeer(info, h) // reach it back at the host we dialed + return nil + } + if lastErr == nil { + lastErr = fmt.Errorf("could not reach %s", host) + } + return lastErr +} + +// hostPort splits an optional :port off host, defaulting to the LocalSend port. +// It handles bare IPv6 by requiring the [::]:port form for a custom port. +func hostPort(host string) (string, int) { + if h, p, err := net.SplitHostPort(host); err == nil { + if n, err := strconv.Atoi(p); err == nil { + return h, n + } + return h, protocol.DefaultPort + } + return host, protocol.DefaultPort +} + // 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. diff --git a/internal/discovery/probe_test.go b/internal/discovery/probe_test.go new file mode 100644 index 0000000..8fa81aa --- /dev/null +++ b/internal/discovery/probe_test.go @@ -0,0 +1,64 @@ +package discovery + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "omarchy-send/internal/protocol" +) + +// TestProbeRegistersPeer drives Probe against a stub /register that behaves like +// a real peer: it records the caller and returns its own DeviceInfo. The https +// attempt fails against the plain-http test server and Probe falls back to http. +func TestProbeRegistersPeer(t *testing.T) { + peerInfo := protocol.DeviceInfo{Alias: "Remote", Fingerprint: "remote-fp", Port: 53317, Protocol: "http"} + var sawOurInfo bool + + mux := http.NewServeMux() + mux.HandleFunc("/api/localsend/v2/register", func(w http.ResponseWriter, r *http.Request) { + var in protocol.DeviceInfo + if err := json.NewDecoder(r.Body).Decode(&in); err == nil && in.Fingerprint == "self-fp" { + sawOurInfo = true + } + _ = json.NewEncoder(w).Encode(peerInfo) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + host := strings.TrimPrefix(srv.URL, "http://") // host:port + + d := New(protocol.DeviceInfo{Fingerprint: "self-fp", Alias: "Self"}) + if err := d.Probe(context.Background(), host); err != nil { + t.Fatalf("Probe failed: %v", err) + } + if !sawOurInfo { + t.Error("peer did not receive our device info in the register body") + } + peers := d.Snapshot() + if len(peers) != 1 || peers[0].Info.Fingerprint != "remote-fp" { + t.Fatalf("peer not recorded as expected: %+v", peers) + } + if wantIP := strings.Split(host, ":")[0]; peers[0].IP != wantIP { + t.Errorf("peer IP = %q, want %q (the host we dialed)", peers[0].IP, wantIP) + } +} + +func TestProbeUnreachableErrors(t *testing.T) { + d := New(protocol.DeviceInfo{Fingerprint: "self-fp"}) + // 127.0.0.1:1 — nothing listening; both https and http should fail fast. + if err := d.Probe(context.Background(), "127.0.0.1:1"); err == nil { + t.Fatal("expected an error probing an unreachable host") + } +} + +func TestHostPortDefaults(t *testing.T) { + if h, p := hostPort("colossus"); h != "colossus" || p != protocol.DefaultPort { + t.Errorf("hostPort(bare) = %q,%d; want colossus,%d", h, p, protocol.DefaultPort) + } + if h, p := hostPort("100.64.0.2:9999"); h != "100.64.0.2" || p != 9999 { + t.Errorf("hostPort(host:port) = %q,%d; want 100.64.0.2,9999", h, p) + } +} diff --git a/internal/tailscale/tailscale.go b/internal/tailscale/tailscale.go new file mode 100644 index 0000000..8606b97 --- /dev/null +++ b/internal/tailscale/tailscale.go @@ -0,0 +1,77 @@ +// Package tailscale discovers peer addresses from a local Tailscale daemon, so +// omarchy-send can reach devices that share a tailnet but not a LAN subnet (and +// therefore can't be found by multicast). It shells out to the `tailscale` CLI +// and is a no-op when that isn't present. +package tailscale + +import ( + "context" + "encoding/json" + "os/exec" +) + +// status is the subset of `tailscale status --json` we care about. +type status struct { + Peer map[string]struct { + TailscaleIPs []string `json:"TailscaleIPs"` + Online bool `json:"Online"` + } `json:"Peer"` +} + +// Available reports whether the tailscale CLI is on PATH. +func Available() bool { + _, err := exec.LookPath("tailscale") + return err == nil +} + +// Peers returns the IPv4 Tailscale address of each online peer in the tailnet. +// It returns nil (no error) when tailscale isn't installed, isn't running, or +// the output can't be parsed — callers treat Tailscale discovery as best-effort. +func Peers(ctx context.Context) []string { + if !Available() { + return nil + } + out, err := exec.CommandContext(ctx, "tailscale", "status", "--json").Output() + if err != nil { + return nil + } + return parsePeers(out) +} + +// parsePeers extracts each online peer's first IPv4 address from the JSON of +// `tailscale status --json`. Split out so it can be tested without the CLI. +func parsePeers(data []byte) []string { + var st status + if err := json.Unmarshal(data, &st); err != nil { + return nil + } + var hosts []string + for _, p := range st.Peer { + if !p.Online { + continue + } + for _, ip := range p.TailscaleIPs { + if isIPv4(ip) { + hosts = append(hosts, ip) + break // one address per peer is enough to probe + } + } + } + return hosts +} + +// isIPv4 reports whether s looks like a dotted-quad (cheap check — avoids +// pulling in net just to skip the IPv6 entries Tailscale also reports). +func isIPv4(s string) bool { + dots := 0 + for _, c := range s { + switch { + case c == '.': + dots++ + case c >= '0' && c <= '9': + default: + return false + } + } + return dots == 3 +} diff --git a/internal/tailscale/tailscale_test.go b/internal/tailscale/tailscale_test.go new file mode 100644 index 0000000..2629a6c --- /dev/null +++ b/internal/tailscale/tailscale_test.go @@ -0,0 +1,46 @@ +package tailscale + +import ( + "reflect" + "sort" + "testing" +) + +func TestParsePeersOnlineIPv4Only(t *testing.T) { + data := []byte(`{ + "Peer": { + "key1": {"TailscaleIPs": ["100.64.0.1", "fd7a:115c::1"], "Online": true}, + "key2": {"TailscaleIPs": ["100.64.0.2"], "Online": false}, + "key3": {"TailscaleIPs": ["fd7a:115c::3"], "Online": true}, + "key4": {"TailscaleIPs": ["100.64.0.4"], "Online": true} + } + }`) + got := parsePeers(data) + sort.Strings(got) + want := []string{"100.64.0.1", "100.64.0.4"} // online + has IPv4; offline and v6-only excluded + if !reflect.DeepEqual(got, want) { + t.Errorf("parsePeers = %v, want %v", got, want) + } +} + +func TestParsePeersBadJSON(t *testing.T) { + if got := parsePeers([]byte("not json")); got != nil { + t.Errorf("bad JSON should yield nil, got %v", got) + } +} + +func TestIsIPv4(t *testing.T) { + cases := map[string]bool{ + "100.64.0.1": true, + "1.2.3.4": true, + "fd7a:115c::1": false, + "1.2.3": false, + "": false, + "abc": false, + } + for in, want := range cases { + if got := isIPv4(in); got != want { + t.Errorf("isIPv4(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/internal/tui/manage_test.go b/internal/tui/manage_test.go index 172f148..19b6bf7 100644 --- a/internal/tui/manage_test.go +++ b/internal/tui/manage_test.go @@ -64,15 +64,15 @@ 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 + 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 + m = key(m, "d") // request delete -> confirm card if !m.confirmDel { t.Fatal("expected confirm card to be showing") } - m = key(m, "y") // confirm + m = key(m, "y") // confirm if m.confirmDel { t.Error("confirm card should be dismissed after delete") } diff --git a/internal/tui/model.go b/internal/tui/model.go index f3c157c..5d8c95a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -38,6 +38,7 @@ type Controller interface { SetReceiveDir(string) SetPIN(string) SetNotify(bool) + AddKnownPeer(host string) // probe a remote host directly (off-LAN / Tailscale) } type screen int @@ -123,6 +124,10 @@ type Model struct { sendPaths []string pendingMsg string + // Add-remote-peer modal (Devices tab): host/IP/Tailscale-name entry. + addingPeer bool + peerInput textinput.Model + // Messages tab + compose modal. msgList list.Model messages []server.ReceivedMessage @@ -199,6 +204,11 @@ func New(cfg config.Config, ctrl Controller, opts ...Option) Model { compose.CharLimit = 2000 compose.Width = 48 + peerInput := textinput.New() + peerInput.Placeholder = "host, IP, or Tailscale name" + peerInput.CharLimit = 256 + peerInput.Width = 48 + mkInput := func(placeholder string, limit int) textinput.Model { ti := textinput.New() ti.Placeholder = placeholder @@ -229,6 +239,7 @@ func New(cfg config.Config, ctrl Controller, opts ...Option) Model { editInputs: editInputs, msgList: ml, composeInput: compose, + peerInput: peerInput, } for _, o := range opts { o(&m) @@ -321,6 +332,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.composing { return m.updateCompose(msg) } + if m.addingPeer { + return m.updateAddPeer(msg) + } if m.readingMsg != nil { switch msg.String() { case "esc", "q", "enter": @@ -387,6 +401,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshManage() } return m, nil + case "+": + // Add a remote device by host/IP/Tailscale-name (off-LAN peer). + if m.screen == screenPeers { + m.addingPeer = true + m.peerInput.SetValue("") + m.peerInput.Focus() + return m, textinput.Blink + } + return m, nil case "r": if m.screen == screenPeers && m.ctrl != nil { m.ctrl.Announce() @@ -547,6 +570,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + if m.addingPeer { + var cmd tea.Cmd + m.peerInput, cmd = m.peerInput.Update(msg) + return m, cmd + } if m.pending == nil && m.screen == screenPicker { var cmd tea.Cmd m.fzfQuery, cmd = m.fzfQuery.Update(msg) @@ -597,6 +625,33 @@ func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +// updateAddPeer handles the add-remote-device modal. On enter it persists the +// host to config's known-peers and asks the controller to probe it now. +func (m Model) updateAddPeer(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.addingPeer = false + return m, nil + case "enter": + host := strings.TrimSpace(m.peerInput.Value()) + if host != "" { + if !contains(m.cfg.KnownPeers, host) { + m.cfg.KnownPeers = append(m.cfg.KnownPeers, host) + _ = m.cfg.Save() + } + if m.ctrl != nil { + m.ctrl.AddKnownPeer(host) + } + m.notice = "added remote " + host + " — probing…" + } + m.addingPeer = false + return m, nil + } + var cmd tea.Cmd + m.peerInput, cmd = m.peerInput.Update(msg) + return m, cmd +} + // deleteSelectedMessage drops the highlighted message from the list. func (m *Model) deleteSelectedMessage() { it, ok := m.msgList.SelectedItem().(msgItem) @@ -848,6 +903,9 @@ func (m Model) View() string { if m.composing { return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.composeView())) } + if m.addingPeer { + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.addPeerView())) + } if m.readingMsg != nil { return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.readMessageView())) } @@ -984,6 +1042,18 @@ func (m Model) pinView() string { return b.String() } +func (m Model) addPeerView() string { + var b strings.Builder + b.WriteString(titleStyle.Render("Add remote device")) + b.WriteString("\n\n") + b.WriteString(headerStyle.Render("A device off your LAN — e.g. a Tailscale name or IP.\nIt's probed directly (no multicast) and saved.")) + b.WriteString("\n\n") + b.WriteString(m.peerInput.View()) + b.WriteString("\n\n") + b.WriteString(footerStyle.Render("enter add · esc cancel")) + return b.String() +} + func (m Model) acceptView() string { var b strings.Builder b.WriteString(titleStyle.Render("Incoming files")) @@ -1233,6 +1303,8 @@ func (m Model) footerText() string { return m.notice case m.composing: return "enter send · esc cancel" + case m.addingPeer: + return "enter add remote · esc cancel" case m.readingMsg != nil: return "y copy · esc/enter close" case m.confirmDel: @@ -1244,7 +1316,7 @@ func (m Model) footerText() string { case m.screen == screenPeers && m.quickSend: return fmt.Sprintf("enter send %d item(s) to selected device · r refresh · q cancel", len(m.staged)) case m.screen == screenPeers: - return "enter send-to · m message · v send-clipboard · r refresh · / filter · 1-5 · q quit" + return "enter send-to · m message · v clipboard · + add remote · r refresh · / filter · q quit" case m.screen == screenTransfers: return "c clear finished · 1-5 switch · q quit" case m.screen == screenManage: diff --git a/internal/tui/send_test.go b/internal/tui/send_test.go index 4e63186..1440881 100644 --- a/internal/tui/send_test.go +++ b/internal/tui/send_test.go @@ -33,6 +33,7 @@ func (f *fakeCtrl) SetAlias(string) {} func (f *fakeCtrl) SetReceiveDir(string) {} func (f *fakeCtrl) SetPIN(string) {} func (f *fakeCtrl) SetNotify(bool) {} +func (f *fakeCtrl) AddKnownPeer(string) {} // writeTree lays out a small fixture tree under a temp dir for walkIndex tests. func writeTree(t *testing.T) string { diff --git a/internal/tui/view_manage.go b/internal/tui/view_manage.go index 4d7cd2f..2b14b51 100644 --- a/internal/tui/view_manage.go +++ b/internal/tui/view_manage.go @@ -24,7 +24,7 @@ type fileItem struct { } func (i fileItem) Title() string { return i.name } -func (i fileItem) Description() string { return "" } +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