Compare commits

..

No commits in common. "a15440ed20a5130c57df48e33833ee6f39d31881" and "5dfaab40186e92951902599e92704aad2d7452f4" have entirely different histories.

12 changed files with 14 additions and 726 deletions

View file

@ -10,10 +10,6 @@ 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 - **Discovery** — multicast announce/listen on `224.0.0.167:53317` plus the HTTP
`/register` handshake, with peer aging. `/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 - **Receive** — incoming files are accepted via a prompt (or auto-accepted) and
written to the receive directory, with live progress. written to the receive directory, with live progress.
- **Send** — pick a peer, then find what to send with a built-in **recursive - **Send** — pick a peer, then find what to send with a built-in **recursive
@ -51,26 +47,9 @@ curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install.
``` ```
This downloads the right binary for your architecture into `~/.local/bin`, and on This downloads the right binary for your architecture into `~/.local/bin`, and on
Omarchy also adds a floating Walker entry (search **Omarchy-Send**) and the Omarchy also adds a floating Walker entry (search **Omarchy-Send**). Override the
Nautilus right-click integration. Override the location with `BIN_DIR=/usr/local/bin`, location with `BIN_DIR=/usr/local/bin`, or pin a version with
or pin a version with `OMARCHY_SEND_VERSION=v0.1.0`. `OMARCHY_SEND_VERSION=v0.1.0`.
The installer also drops an **agent context file** so any AI agent on the machine
(Claude, etc.) knows what omarchy-send is and where received files land: a canonical
`~/.config/omarchy-send/AGENTS.md` (with a `CLAUDE.md` symlink) plus a short,
idempotently-managed section appended to `~/.claude/CLAUDE.md`.
**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`.
If you install in local mode but the box has a **public IP**, the installer detects
it and prints a warning with the exact commands to lock the port down — it never
changes your firewall without remote mode. See
[Public-IP boxes](#public-ip-boxes-firewall-the-port) below.
> The installer is a short shell script fetched over HTTPS; read it first if you > 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. > prefer — it lives at [`install.sh`](install.sh) in this repo.
@ -132,66 +111,6 @@ Staging a folder sends it whole (its structure is recreated on the receiver).
Matching is case-insensitive, and noisy directories (`.git`, `node_modules`, Matching is case-insensitive, and noisy directories (`.git`, `node_modules`,
caches, dotfiles…) are skipped to keep the index fast. 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).
#### Public-IP boxes: firewall the port
The receiver binds **all interfaces**, so on a box with a public IP, port `53317`
is reachable from the open internet while the TUI is running. Don't leave it that
way. Three ways to handle it:
- **Easiest:** install in **remote** mode — `OMARCHY_SEND_MODE=remote bash install.sh`
— and the installer applies the `ufw` rules for you (when a real `tailscale0`
interface is present).
- **Manually**, restrict the port to the tailnet:
```sh
ufw allow in on tailscale0 to any port 53317 # tailnet only
ufw deny 53317 # everything else
```
Or the `nftables` equivalent (inet filter, input chain):
```
iifname "tailscale0" tcp dport 53317 accept
tcp dport 53317 drop
```
- **Inside a container** (e.g. Docker `--network host` with userspace-networking
Tailscale, where there's no `tailscale0` and no `CAP_NET_ADMIN`): you can't
firewall from in there — apply it on the **host**. If the host already
default-denies inbound (only opens e.g. 22/80/443), `53317` is already blocked
from the internet yet still reachable over the tailnet (tailscaled delivers it
via loopback) — nothing more to do.
Always set a **`--pin`** as a second layer regardless.
> **Verifying** the port is closed: don't trust `nc -z`, `telnet`, or
> `/dev/tcp` — some hosting providers (Hostinger, DigitalOcean, …) answer the TCP
> handshake (SYN/ACK) for *every* port at their network edge, so those tools
> report a firewalled port as "open". Only an **app-layer** probe is truthful:
>
> ```sh
> curl -sk https://<public-ip>:53317/api/localsend/v2/info # should time out / hang
> curl -sk https://<tailnet-ip>:53317/api/localsend/v2/info # returns device info
> ```
### Right-click send (Nautilus) ### Right-click send (Nautilus)
On an Omarchy desktop, the installer adds a **"Send via Omarchy-Send"** entry to On an Omarchy desktop, the installer adds a **"Send via Omarchy-Send"** entry to
@ -235,7 +154,7 @@ omarchy-send --auto-accept --pin 2468
### Keys ### Keys
- `1``5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings - `1``5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings
- Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `+` add a remote device · `r` refresh · `/` filter - Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `r` refresh · `/` filter
- PIN-protected peers: messages prompt for the PIN and retry, just like file sends - 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 - 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 - Incoming prompt: `y` accept · `n` reject

View file

@ -12,7 +12,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -25,7 +24,6 @@ import (
"omarchy-send/internal/discovery" "omarchy-send/internal/discovery"
"omarchy-send/internal/notify" "omarchy-send/internal/notify"
"omarchy-send/internal/server" "omarchy-send/internal/server"
"omarchy-send/internal/tailscale"
"omarchy-send/internal/transfer" "omarchy-send/internal/transfer"
"omarchy-send/internal/tui" "omarchy-send/internal/tui"
) )
@ -36,85 +34,6 @@ type controller struct {
sender *client.Sender sender *client.Sender
srv *server.Server srv *server.Server
notify *atomic.Bool // live gate for desktop notifications (toggled from Settings) 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() } func (c controller) Announce() { c.disc.Announce() }
@ -267,9 +186,7 @@ func main() {
// only carries the user preference here. // only carries the user preference here.
notifyOn := &atomic.Bool{} notifyOn := &atomic.Bool{}
notifyOn.Store(!cfg.NoNotify) notifyOn.Store(!cfg.NoNotify)
rem := &remotes{hosts: cfg.KnownPeers} ctrl := controller{disc: disc, sender: sender, srv: srv, notify: notifyOn}
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()) p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen())
app.BridgeDiscovery(ctx, disc.Events(), p.Send) app.BridgeDiscovery(ctx, disc.Events(), p.Send)
@ -305,9 +222,7 @@ func runQuickSend(cfg config.Config, paths []string) int {
// No receiver in quick-send mode, so nothing to notify about. // No receiver in quick-send mode, so nothing to notify about.
notifyOff := &atomic.Bool{} notifyOff := &atomic.Bool{}
rem := &remotes{hosts: cfg.KnownPeers} ctrl := controller{disc: disc, sender: sender, srv: nil, notify: notifyOff}
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()) p := tea.NewProgram(tui.New(cfg, ctrl, tui.WithStagedFiles(paths)), tea.WithAltScreen())
app.BridgeDiscovery(ctx, disc.Events(), p.Send) app.BridgeDiscovery(ctx, disc.Events(), p.Send)

View file

@ -14,15 +14,11 @@
# Environment overrides: # Environment overrides:
# BIN_DIR=/usr/local/bin install location (default ~/.local/bin) # BIN_DIR=/usr/local/bin install location (default ~/.local/bin)
# OMARCHY_SEND_VERSION=v0.1.0 pin a release (default: latest) # 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: # Behaviour:
# - Local machine (home/LAN): installs the TUI; on Omarchy also adds a Walker # - Headless system: installs the plain `omarchy-send` TUI binary.
# entry + the Nautilus right-click integration. # - Omarchy desktop: additionally adds a Walker entry that launches it as a
# - Remote server (public IP): same install, then restricts port 53317 to the # floating TUI (via the stock TUI.float app-id), like the Wi-Fi TUI.
# Tailscale interface in the firewall, so it's reachable over the tailnet
# only — not the open internet.
set -euo pipefail set -euo pipefail
@ -31,7 +27,6 @@ BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
APP_DIR="$HOME/.local/share/applications" APP_DIR="$HOME/.local/share/applications"
BIN="$BIN_DIR/omarchy-send" BIN="$BIN_DIR/omarchy-send"
VERSION="${OMARCHY_SEND_VERSION:-latest}" VERSION="${OMARCHY_SEND_VERSION:-latest}"
PORT=53317
mkdir -p "$BIN_DIR" mkdir -p "$BIN_DIR"
@ -41,30 +36,6 @@ if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
fi 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 --------------------------------------------------- # ---- obtain the binary ---------------------------------------------------
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.mod" ] && command -v go >/dev/null 2>&1; then if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.mod" ] && command -v go >/dev/null 2>&1; then
echo "==> Building omarchy-send from source..." echo "==> Building omarchy-send from source..."
@ -256,203 +227,6 @@ else
echo "==> Headless system — installed as a plain TUI." echo "==> Headless system — installed as a plain TUI."
fi fi
# ---- agent context (AGENTS.md / CLAUDE.md) -------------------------------
# Install a context file so any AI agent on this machine (Claude, etc.) knows
# what omarchy-send is and — crucially — WHERE files land when other devices
# send them here. The canonical copy lives in the config dir as AGENTS.md (the
# cross-agent standard) with a CLAUDE.md symlink beside it; a short managed
# section is also added to ~/.claude/CLAUDE.md so it's known in every session.
CFG_DIR="$HOME/.config/omarchy-send"
mkdir -p "$CFG_DIR"
# Resolve the real receive directory: the config's receiveDir wins, else default.
RECV_DIR="$HOME/Omarchy-Send"
if [ -f "$CFG_DIR/config.json" ]; then
_rd="$(grep -oE '"receiveDir"[[:space:]]*:[[:space:]]*"[^"]*"' "$CFG_DIR/config.json" \
| sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/' | head -n1)"
[ -n "${_rd:-}" ] && RECV_DIR="$_rd"
fi
# AGENTS.md — quoted heredoc (so backticks stay literal); @@RECV_DIR@@ filled after.
cat > "$CFG_DIR/AGENTS.md" <<'DOC'
# Omarchy-Send — context for AI agents
**What it is:** Omarchy-Send is a terminal (TUI) file-transfer tool installed on
this machine. It speaks the LocalSend protocol, so phones, desktops, and other
servers can send **files** and **plain-text messages** to this machine over the
local network or a Tailscale tailnet. It can also send outbound.
**Where received files live —** incoming files are saved under the *receive
directory*:
@@RECV_DIR@@
That is the current value; the authoritative source is the `receiveDir` key in
`~/.config/omarchy-send/config.json`read it if unsure. Details:
- A file still transferring has a temporary `.part` suffix and is renamed to its
real name only when complete. Treat `*.part` files as incomplete — skip them.
- Filename collisions are de-duplicated (e.g. `photo (1).jpg`).
- A sent *folder* is recreated as a subdirectory tree under the receive dir.
- Plain-text **messages** are not written to disk — they appear in the TUI's
Messages tab while the receiver is running.
**How it runs:** files are received only while a receiver is running — it is a
foreground TUI, not a background daemon. Start it with:
omarchy-send
On a headless box, run it inside a TTY (tmux, or `ssh -t`). It listens on TCP
port **53317**. Auto-accept and an optional PIN live in the config / Settings tab.
**Config:** `~/.config/omarchy-send/config.json`
(keys: `alias`, `receiveDir`, `port`, `autoAccept`, `pin`, `knownPeers`, …).
**If asked to "find / process what was just sent":** look in the receive
directory above and skip any `*.part` files (still transferring).
DOC
sed -i "s|@@RECV_DIR@@|$RECV_DIR|g" "$CFG_DIR/AGENTS.md"
ln -sf AGENTS.md "$CFG_DIR/CLAUDE.md"
echo "==> Wrote agent context: $CFG_DIR/AGENTS.md (+ CLAUDE.md symlink)."
# Managed, idempotent section in the user-global Claude memory.
CLAUDE_MD="$HOME/.claude/CLAUDE.md"
mkdir -p "$HOME/.claude"
[ -f "$CLAUDE_MD" ] || : > "$CLAUDE_MD"
BEGIN_MARK="<!-- BEGIN omarchy-send (managed by installer) -->"
END_MARK="<!-- END omarchy-send (managed by installer) -->"
# Build the fresh block (placeholder substituted) in a temp file.
blk="$(mktemp)"
cat > "$blk" <<'BLK'
<!-- BEGIN omarchy-send (managed by installer) -->
## Omarchy-Send (installed on this machine)
Omarchy-Send is a LocalSend-compatible terminal file-transfer tool; other devices
send files/messages to this box over LAN/Tailscale (TCP 53317). **Files sent here
land in `@@RECV_DIR@@`** (authoritative: the `receiveDir` key in
`~/.config/omarchy-send/config.json`). Files still transferring carry a `.part`
suffix — skip them. Text messages appear in the TUI's Messages tab, not on disk.
Receiving requires the TUI running (`omarchy-send`; use tmux or `ssh -t` when
headless). Full notes: `~/.config/omarchy-send/AGENTS.md`.
<!-- END omarchy-send (managed by installer) -->
BLK
sed -i "s|@@RECV_DIR@@|$RECV_DIR|g" "$blk"
# Strip any prior managed block, then append the fresh one (no duplicates on re-run).
new_cm="$(mktemp)"
awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
$0==b {skip=1} skip && $0==e {skip=0; next} !skip' "$CLAUDE_MD" > "$new_cm"
# Drop a trailing blank line then re-add exactly one before the block, for tidiness.
{ cat "$new_cm"; printf '\n'; cat "$blk"; } > "$CLAUDE_MD"
rm -f "$blk" "$new_cm"
echo " Added an Omarchy-Send section to $CLAUDE_MD."
# ---- 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
# A routable public IPv4 means $PORT is reachable from the internet unless
# firewalled. Excludes loopback, link-local, RFC1918 and CGNAT/Tailscale
# (100.64.0.0/10). Empty when the box is purely on private/tailnet addresses.
PUBLIC_IP="$(ip -o -4 addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 \
| grep -vE '^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.)' \
| head -n1 || true)"
# ---- 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
# ---- local mode on a public-IP box: inform, don't touch the firewall -----
# We never change the firewall outside remote mode, but a public IP means the
# port is internet-exposed while the TUI is open — so surface it with the exact
# commands to lock it down. (Covers the silent `curl | bash` default-to-local
# case, where the interactive remote prompt never ran.)
if [ "$MODE" != "remote" ] && [ -n "$PUBLIC_IP" ]; then
iface="${TS_IFACE:-tailscale0}"
echo
echo "⚠ Heads up: this machine has a public IP ($PUBLIC_IP) and was installed in"
echo " LOCAL mode, so port $PORT was NOT firewalled. The receiver binds all"
echo " interfaces, so $PORT is reachable from the internet while the TUI is open."
echo " The installer won't change your firewall without remote mode — lock it to"
echo " your tailnet yourself (recommended):"
if [ "$IN_CONTAINER" = "1" ]; then
echo " • You're in a container — apply on the HOST, not in here: ufw deny $PORT"
echo " (if the host already default-denies inbound, $PORT is already blocked"
echo " from the internet yet still reachable over the tailnet via loopback)."
elif command -v ufw >/dev/null 2>&1; then
echo " • ufw allow in on $iface to any port $PORT"
echo " • ufw deny $PORT"
else
echo " • nftables (inet filter, input chain):"
echo " iifname \"$iface\" tcp dport $PORT accept"
echo " tcp dport $PORT drop"
fi
echo " Or re-run to firewall it automatically: OMARCHY_SEND_MODE=remote bash install.sh"
echo " And/or set a PIN: omarchy-send --pin <code>"
echo " Verify with an app-layer probe (raw TCP/nc lie behind some providers):"
echo " curl -sk https://<public-ip>:$PORT/api/localsend/v2/info # should time out"
fi
echo echo
case ":$PATH:" in case ":$PATH:" in
*":$BIN_DIR:"*) : ;; *":$BIN_DIR:"*) : ;;

View file

@ -25,11 +25,6 @@ type Config struct {
NoIcons bool `json:"noIcons"` // hide Nerd Font device icons (non-NF terminals) 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 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. // TLS identity for encrypted (HTTPS) mode, generated once and persisted.
CertPEM string `json:"certPem"` CertPEM string `json:"certPem"`
KeyPEM string `json:"keyPem"` KeyPEM string `json:"keyPem"`

View file

@ -295,61 +295,6 @@ func (d *Discoverer) reply(ip string, port int, proto string) {
_ = resp.Body.Close() _ = 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 // NotePeer records a peer (from multicast or from an inbound /register) and
// emits PeerFound on first sight or when its address changes. Safe for // emits PeerFound on first sight or when its address changes. Safe for
// concurrent use; ignores our own fingerprint. // concurrent use; ignores our own fingerprint.

View file

@ -1,64 +0,0 @@
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

@ -1,77 +0,0 @@
// 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

@ -1,46 +0,0 @@
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

@ -64,15 +64,15 @@ func TestManageDeleteSingleViaConfirm(t *testing.T) {
m, dir := manageModel(t, "keep.txt", "drop.txt") m, dir := manageModel(t, "keep.txt", "drop.txt")
// Cursor starts on the newest (drop.txt was written last). Marking it and // Cursor starts on the newest (drop.txt was written last). Marking it and
// confirming should remove exactly that file. // 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 { if len(m.marked) != 1 {
t.Fatalf("expected 1 marked, got %d", len(m.marked)) 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 { if !m.confirmDel {
t.Fatal("expected confirm card to be showing") t.Fatal("expected confirm card to be showing")
} }
m = key(m, "y") // confirm m = key(m, "y") // confirm
if m.confirmDel { if m.confirmDel {
t.Error("confirm card should be dismissed after delete") t.Error("confirm card should be dismissed after delete")
} }

View file

@ -38,7 +38,6 @@ type Controller interface {
SetReceiveDir(string) SetReceiveDir(string)
SetPIN(string) SetPIN(string)
SetNotify(bool) SetNotify(bool)
AddKnownPeer(host string) // probe a remote host directly (off-LAN / Tailscale)
} }
type screen int type screen int
@ -124,10 +123,6 @@ type Model struct {
sendPaths []string sendPaths []string
pendingMsg string pendingMsg string
// Add-remote-peer modal (Devices tab): host/IP/Tailscale-name entry.
addingPeer bool
peerInput textinput.Model
// Messages tab + compose modal. // Messages tab + compose modal.
msgList list.Model msgList list.Model
messages []server.ReceivedMessage messages []server.ReceivedMessage
@ -204,11 +199,6 @@ func New(cfg config.Config, ctrl Controller, opts ...Option) Model {
compose.CharLimit = 2000 compose.CharLimit = 2000
compose.Width = 48 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 { mkInput := func(placeholder string, limit int) textinput.Model {
ti := textinput.New() ti := textinput.New()
ti.Placeholder = placeholder ti.Placeholder = placeholder
@ -239,7 +229,6 @@ func New(cfg config.Config, ctrl Controller, opts ...Option) Model {
editInputs: editInputs, editInputs: editInputs,
msgList: ml, msgList: ml,
composeInput: compose, composeInput: compose,
peerInput: peerInput,
} }
for _, o := range opts { for _, o := range opts {
o(&m) o(&m)
@ -332,9 +321,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.composing { if m.composing {
return m.updateCompose(msg) return m.updateCompose(msg)
} }
if m.addingPeer {
return m.updateAddPeer(msg)
}
if m.readingMsg != nil { if m.readingMsg != nil {
switch msg.String() { switch msg.String() {
case "esc", "q", "enter": case "esc", "q", "enter":
@ -401,15 +387,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshManage() m.refreshManage()
} }
return m, nil 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": case "r":
if m.screen == screenPeers && m.ctrl != nil { if m.screen == screenPeers && m.ctrl != nil {
m.ctrl.Announce() m.ctrl.Announce()
@ -570,11 +547,6 @@ 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 { if m.pending == nil && m.screen == screenPicker {
var cmd tea.Cmd var cmd tea.Cmd
m.fzfQuery, cmd = m.fzfQuery.Update(msg) m.fzfQuery, cmd = m.fzfQuery.Update(msg)
@ -625,33 +597,6 @@ func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, 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. // deleteSelectedMessage drops the highlighted message from the list.
func (m *Model) deleteSelectedMessage() { func (m *Model) deleteSelectedMessage() {
it, ok := m.msgList.SelectedItem().(msgItem) it, ok := m.msgList.SelectedItem().(msgItem)
@ -903,9 +848,6 @@ func (m Model) View() string {
if m.composing { if m.composing {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.composeView())) 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 { if m.readingMsg != nil {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.readMessageView())) return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.readMessageView()))
} }
@ -1042,18 +984,6 @@ func (m Model) pinView() string {
return b.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 { func (m Model) acceptView() string {
var b strings.Builder var b strings.Builder
b.WriteString(titleStyle.Render("Incoming files")) b.WriteString(titleStyle.Render("Incoming files"))
@ -1303,8 +1233,6 @@ func (m Model) footerText() string {
return m.notice return m.notice
case m.composing: case m.composing:
return "enter send · esc cancel" return "enter send · esc cancel"
case m.addingPeer:
return "enter add remote · esc cancel"
case m.readingMsg != nil: case m.readingMsg != nil:
return "y copy · esc/enter close" return "y copy · esc/enter close"
case m.confirmDel: case m.confirmDel:
@ -1316,7 +1244,7 @@ func (m Model) footerText() string {
case m.screen == screenPeers && m.quickSend: case m.screen == screenPeers && m.quickSend:
return fmt.Sprintf("enter send %d item(s) to selected device · r refresh · q cancel", len(m.staged)) return fmt.Sprintf("enter send %d item(s) to selected device · r refresh · q cancel", len(m.staged))
case m.screen == screenPeers: case m.screen == screenPeers:
return "enter send-to · m message · v clipboard · + add remote · r refresh · / filter · q quit" return "enter send-to · m message · v send-clipboard · r refresh · / filter · 1-5 · q quit"
case m.screen == screenTransfers: case m.screen == screenTransfers:
return "c clear finished · 1-5 switch · q quit" return "c clear finished · 1-5 switch · q quit"
case m.screen == screenManage: case m.screen == screenManage:

View file

@ -33,7 +33,6 @@ func (f *fakeCtrl) SetAlias(string) {}
func (f *fakeCtrl) SetReceiveDir(string) {} func (f *fakeCtrl) SetReceiveDir(string) {}
func (f *fakeCtrl) SetPIN(string) {} func (f *fakeCtrl) SetPIN(string) {}
func (f *fakeCtrl) SetNotify(bool) {} func (f *fakeCtrl) SetNotify(bool) {}
func (f *fakeCtrl) AddKnownPeer(string) {}
// writeTree lays out a small fixture tree under a temp dir for walkIndex tests. // writeTree lays out a small fixture tree under a temp dir for walkIndex tests.
func writeTree(t *testing.T) string { func writeTree(t *testing.T) string {

View file

@ -24,7 +24,7 @@ type fileItem struct {
} }
func (i fileItem) Title() string { return i.name } 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 } func (i fileItem) FilterValue() string { return i.name }
// receivedFiles lists the top-level entries in dir, newest first. In-progress // receivedFiles lists the top-level entries in dir, newest first. In-progress