Probe known and Tailscale peers in headless send

runHeadlessSend only ran multicast discovery, so `--to <alias> --message`
could not reach a peer that is only routable over the tailnet (or another
subnet), even though the TUI and quick-send paths could via watchRemotes.
Start the same watcher in the headless path: known peers from config plus
online Tailscale peers are unicast-probed, the probe handshake records the
peer, and FindPeer picks it up like any multicast discovery.

Verified live against a tailnet-only peer (different subnet, DERP-relayed):
discovered and message delivered. TestFindPeerViaProbe covers the
composition Probe -> NotePeer -> PeerFound -> FindPeer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-06-06 12:18:04 +01:00
parent a15440ed20
commit 965e933ad2
3 changed files with 53 additions and 6 deletions

View file

@ -111,11 +111,14 @@ omarchy-send --to "Strong Onion" --message "hi" --send-pin 2468 # if the peer
```
The target is matched against the peer's display name, case-insensitively. The
command discovers the peer over multicast (waiting up to `--wait`, default 15s),
sends the message, prints a one-line result, and exits non-zero if the peer
isn't found or the send fails. It starts discovery only — not the receiver — so
it's safe to run while another `omarchy-send` instance is up. Both `--to` and
`--message` are required; file sending stays in the TUI for now.
command discovers the peer over multicast and, like the TUI, directly probes
your known peers and online Tailscale peers (waiting up to `--wait`, default
15s) — so a remote box added with `+` in the TUI, or any tailnet peer, is a
valid `--to` target from a script too. It sends the message, prints a one-line
result, and exits non-zero if the peer isn't found or the send fails. It starts
discovery only — not the receiver — so it's safe to run while another
`omarchy-send` instance is up. Both `--to` and `--message` are required; file
sending stays in the TUI for now.
### Sending files

View file

@ -337,6 +337,11 @@ func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait ti
}
disc.Announce() // solicit replies immediately rather than waiting a tick
// Multicast can't cross subnets or the tailnet, so also probe known peers
// and online Tailscale peers directly — same as the TUI's device list.
rem := &remotes{hosts: cfg.KnownPeers}
go watchRemotes(ctx, disc, rem)
want := strings.TrimSpace(target)
fmt.Fprintf(os.Stderr, "Looking for %q on the network (up to %s)…\n", want, wait)
@ -353,7 +358,7 @@ func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait ti
fmt.Fprintf(os.Stderr, " - %q (%s)\n", p.Info.Alias, p.IP)
}
} else {
fmt.Fprintln(os.Stderr, "No peers were seen at all — check you're on the same LAN and the target is running omarchy-send / LocalSend.")
fmt.Fprintln(os.Stderr, "No peers were seen at all — check the target is running omarchy-send / LocalSend on the same LAN, or is reachable as a known peer / over Tailscale.")
}
return 1
}

View file

@ -7,6 +7,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"omarchy-send/internal/protocol"
)
@ -46,6 +47,44 @@ func TestProbeRegistersPeer(t *testing.T) {
}
}
// TestFindPeerViaProbe covers the headless-send path for remote peers: a peer
// that multicast can't see (here: only reachable by unicast Probe) must still
// satisfy a FindPeer that is already waiting — Probe → NotePeer → PeerFound.
func TestFindPeerViaProbe(t *testing.T) {
peerInfo := protocol.DeviceInfo{Alias: "titan-box", Fingerprint: "titan-fp", Port: 53317, Protocol: "http"}
mux := http.NewServeMux()
mux.HandleFunc("/api/localsend/v2/register", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(peerInfo)
})
srv := httptest.NewServer(mux)
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")
d := New(protocol.DeviceInfo{Fingerprint: "self-fp", Alias: "Self"})
// Probe concurrently, like watchRemotes does while FindPeer waits.
go func() {
if err := d.Probe(context.Background(), host); err != nil {
t.Errorf("Probe failed: %v", err)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
got, err := d.FindPeer(ctx, func(p Peer) bool {
return strings.EqualFold(strings.TrimSpace(p.Info.Alias), "titan-box")
})
if err != nil {
t.Fatalf("FindPeer did not see the probed peer: %v", err)
}
if got.Info.Fingerprint != "titan-fp" {
t.Errorf("fingerprint = %q, want titan-fp", got.Info.Fingerprint)
}
if wantIP := strings.Split(host, ":")[0]; got.IP != wantIP {
t.Errorf("peer IP = %q, want %q (the host probed)", got.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.