Add remote-peer support over Tailscale and unicast probing

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) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-06-02 21:47:52 +01:00
parent 5dfaab4018
commit 2c058031fd
12 changed files with 547 additions and 14 deletions

View file

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

View file

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

View file

@ -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 <code>)."
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 <code> (or set it in Settings)."
fi
echo
case ":$PATH:" in
*":$BIN_DIR:"*) : ;;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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