Compare commits
No commits in common. "a15440ed20a5130c57df48e33833ee6f39d31881" and "5dfaab40186e92951902599e92704aad2d7452f4" have entirely different histories.
a15440ed20
...
5dfaab4018
12 changed files with 14 additions and 726 deletions
89
README.md
89
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
232
install.sh
232
install.sh
|
|
@ -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:"*) : ;;
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue