diff --git a/README.md b/README.md index 211d615..113ac29 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/omarchy-send/main.go b/cmd/omarchy-send/main.go index 84f6852..f01a59b 100644 --- a/cmd/omarchy-send/main.go +++ b/cmd/omarchy-send/main.go @@ -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 } diff --git a/internal/discovery/probe_test.go b/internal/discovery/probe_test.go index 8fa81aa..d7e239b 100644 --- a/internal/discovery/probe_test.go +++ b/internal/discovery/probe_test.go @@ -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.