Compare commits

...

18 commits
v0.1.2 ... main

Author SHA1 Message Date
39280e5590 README: clarify not affiliated with or endorsed by LocalSend
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:30:37 +01:00
d68421ac97 install.sh: CLI file sends + OSF shorthand in agent context
The installed AGENTS.md / CLAUDE.md now document the headless file-send
form and teach agents that "OSF" is user shorthand for omarchy-send
("OSF report.pdf to gav" → omarchy-send -to gav report.pdf).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:13:25 +01:00
2342beff10 Send files and folders headlessly with -to
`omarchy-send -to <alias> <paths…>` now sends files without the TUI —
from scripts, cron, or AI agents — alongside or instead of -message.
Folders are sent whole with their structure recreated on the receiver,
and a result line is printed per file. Paths without -to still open the
TUI quick-send as before.

The new SendFilesSync mirrors SendMessageSync: it blocks until the batch
lands and returns errors directly (including ErrPinRequired). A missing
path is a hard error up front — a script wants a non-zero exit, not a
silent skip — while a file that vanishes mid-batch is skipped and
reported, like the TUI path. The -dir override also rides through the
new config.ExpandHome.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:13:25 +01:00
a93cdcaa83 Expand ~ in receiveDir everywhere it becomes a filesystem path
A receiveDir stored as "~/Omarchy-Send" (hand-edited, or typed into the
Settings tab) was treated as a relative path, so the receiver silently
created a literal "~" directory under its cwd and wrote incoming files
there. The TUI expanded ~ for display only, which hid the problem.

The canonical ExpandHome now lives in config and is applied in Load
(normalised value is persisted back), in the Settings-tab save, and by
the TUI's display helper, which now delegates to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:13:13 +01:00
38f057db67 install.sh: offer to enable tailscaled's SOCKS5 proxy ([y/N])
When userspace-networking Tailscale is detected with no proxy at
localhost:1055, don't just print the fix — offer to apply it: patch the
flag into any writable launcher script that starts tailscaled (e.g. a
container entrypoint, so it persists across restarts) and restart the
daemon with its existing flags plus --socks5-server. When the installer
lacks the rights to restart it (tailscaled is usually root's), it
patches the launcher and says to restart the container/box instead.

Defaults to no, and non-interactive installs never touch the daemon;
OMARCHY_SEND_FIX_TAILSCALE=yes|no pre-answers the prompt. The restart
warning notes it briefly drops the tailnet, including tailscale SSH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:07:33 +01:00
ee9060e6d0 install.sh: don't die when no tailscale interface exists
Under set -euo pipefail, the TS_IFACE detection pipeline killed the
whole script when grep matched nothing — i.e. on every box without a
tailscaleN interface (containers under userspace networking, or no
tailscale at all). Everything after the agent-context step was silently
skipped: firewall advice, the public-IP warning, the new proxy check.
Latent since v0.1.8; first surfaced on a real container install. Guard
the receiveDir extraction the same way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:32:25 +01:00
05e70f127d install.sh: userspace-proxy check + CLI-send agent context
Detect a userspace-networking tailscaled with no SOCKS5 proxy at
localhost:1055 and print exactly how to enable it (the box can receive
but not send to the tailnet until then); confirm when the proxy is
already there.

Teach the installed agent context (AGENTS.md + the managed ~/.claude/
CLAUDE.md section) that messages can be SENT from the CLI — agents knew
where received files land but not that omarchy-send -to/-message exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:29:14 +01:00
7d6e339dcb Route tailnet traffic via tailscaled's SOCKS5 proxy on userspace boxes
Under tailscaled --tun=userspace-networking (no TUN device — the default
in unprivileged containers), processes cannot dial tailnet addresses at
all: inbound connections are proxied to loopback by tailscaled, but
outbound 100.64.0.0/10 dials have no route. omarchy-send could receive
on such boxes but never send, probe, or discover over the tailnet.

New internal/tsproxy auto-detects this: when no local interface carries
a tailnet address but a SOCKS5 proxy answers at localhost:1055 (the
conventional --socks5-server address), tailnet-bound connections route
through it. LAN traffic is never proxied; explicit HTTPS_PROXY/
HTTP_PROXY/NO_PROXY win over detection, restoring the env-var support
the custom transports had silently dropped.

Also guard NotePeer against the loopback trap the same proxying causes:
inbound registers all appear to come from 127.0.0.1, and recording that
over a peer's routable address made a TUI "send" loop back to our own
receiver while looking delivered.

Live-verified from an omaterm container (userspace tailscaled): headless
send discovered and messaged a tailnet peer with no env vars set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:29:14 +01:00
ff67bd6a71 Probe known and Tailscale peers in headless send
runHeadlessSend only ran multicast discovery, so `--to <alias> --message`
could not reach a peer that is only routable over the tailnet (or another
subnet), even though the TUI and quick-send paths could via watchRemotes.
Start the same watcher in the headless path: known peers from config plus
online Tailscale peers are unicast-probed, the probe handshake records the
peer, and FindPeer picks it up like any multicast discovery.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:18:04 +01:00
a15440ed20 Install AGENTS.md/CLAUDE.md agent context with omarchy-send
So any AI agent on the machine knows what omarchy-send is and — crucially —
where files land when other devices send them here. The installer writes a
canonical ~/.config/omarchy-send/AGENTS.md (with a CLAUDE.md symlink) and
appends a managed, idempotent section to ~/.claude/CLAUDE.md (markers prevent
duplicates on re-run; the receive dir is read from config when present).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:47:52 +01:00
e57c426327 Warn when installing on a public-IP box in local mode
A piped `curl | bash` defaults to local mode and previously said nothing about
the transfer port being internet-exposed. Now the installer detects a routable
public IPv4 (excluding RFC1918, loopback, link-local and CGNAT/Tailscale) and,
in local mode, prints the exact ufw/nftables commands to lock 53317 to the
tailnet — plus a container-on-host note, a --pin tip, and an app-layer verify
command (raw TCP/nc lie behind providers that SYN-ACK every port). It never
changes the firewall outside remote mode. README gains a "Public-IP boxes"
section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:47:52 +01:00
2c058031fd Add remote-peer support over Tailscale and unicast probing
Multicast only finds peers on the same LAN. Reach off-LAN boxes by probing
them directly over unicast (works over any routable address; Tailscale is the
easy, secure choice):

- discovery.Probe: unicast POST /register (https->http fallback) with a two-way
  handshake, so send and receive both work; offline peers age out.
- internal/tailscale: Peers() shells `tailscale status --json` for online peers.
- config.KnownPeers: persisted manual remotes.
- main.go: watchRemotes goroutine probes knownPeers ∪ tailscale peers every 10s,
  in both the normal TUI path and quick-send.
- TUI: `+` on Devices opens an add-remote modal (host/IP/Tailscale name).
- install.sh: interactive local/remote install prompt; remote mode locks port
  53317 to the Tailscale interface (ufw), with container/userspace-networking
  detection. README documents remote devices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:47:52 +01:00
5dfaab4018 Ignore dist/ build output
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:47:49 +01:00
cfa719e2b4 Add fuzzy send finder, quick-send, and Nautilus right-click
Send picker: replace the directory browser with a recursive fuzzy finder.
Type to match files/folders anywhere under $HOME (sahilm/fuzzy, now a direct
dep, compiled in), stage with enter, ctrl+d folders-only, ctrl+s send,
ctrl+u re-root up. Folder send is preserved (staging a dir sends it whole).

Quick-send: `omarchy-send <paths>` opens the TUI on the device list with the
paths pre-staged; selecting a device sends immediately and the window closes
itself when the transfer completes. Runs server-less so it coexists with an
already-running instance (controller Set* methods nil-guard the server).

Nautilus right-click "Send via Omarchy-Send" (desktop-only): a nautilus-python
extension that launches quick-send in a floating terminal with the selection
pre-staged. Resolves omarchy-send to an absolute path since ~/.local/bin is
not on Nautilus's session PATH. install.sh ships it only where Nautilus is
present, so headless boxes skip it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:46:05 +01:00
e66662feb9 Headless one-shot message send + desktop notifications on incoming
Two related additions for using omarchy-send beyond the focused TUI:

Headless send (no TUI / no TTY) — for scripts, cron, SSH sessions:
  omarchy-send --to "<alias>" --message "<text>" [--send-pin N] [--wait 15s]
Resolves the peer by alias over multicast (discovery only, not the HTTP
receiver, so it co-exists with a running instance), sends, prints a
one-line result, and exits non-zero on not-found / failure. New
discovery.FindPeer/Snapshot and client.SendMessageSync (returns the error
directly, incl. ErrPinRequired).

Bugfix surfaced by the above: the official LocalSend client answers a
message prepare-upload with HTTP 204 No Content (the text rides in the
preview field, nothing to upload). prepareUpload only accepted 200, so
message sends to official peers failed with "prepare-upload status 204"
in BOTH the new headless path and the existing TUI. Now treats 204 as
success (empty response).

Desktop notifications: a running receiver raises a notify-send
notification on an incoming message or file offer, so mako shows it on
Omarchy/Hyprland even when the TUI isn't focused. New internal/notify
(best-effort; self-disabling on headless boxes with no notify-send /
session bus). Off-switch: --no-notify flag, cfg.NoNotify, and a Settings
'n' toggle wired to a live atomic gate via Controller.SetNotify so it
takes effect without a restart.

Tests: discovery/find_test, client/message_sync_test,
client/prepare_204_test, app/events_test. Race-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:13:43 +01:00
098c34c546 Clipboard send/copy, PIN for messages, and quiet stdlib logging
- Messages to a PIN-protected peer now prompt for the PIN and retry, like
  file sends (was a silent failure). SendMessage takes a pin argument and the
  TUI tracks whether a pending send is a message or files.
- Copy a received message to the clipboard with `y` (in the Messages tab or
  while reading one).
- Send the clipboard as a message with `v` on a device: it opens the compose
  box pre-filled with the clipboard text to review before sending.
- New internal/clipboard package shells out to wl-clipboard / xclip / xsel,
  and falls back to tmux's paste buffer when running inside tmux on a headless
  box (tmux load-buffer -w also reaches the outer clipboard via set-clipboard,
  which omaterm enables by default). No system tool -> "clipboard unavailable".
- Route the standard logger to the debug log (or discard) at startup so stray
  stdlib logging — e.g. net/http's "unsolicited response on idle channel"
  notice after a peer sends a late response — can't paint over the TUI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:06:24 +01:00
7ab3f905ee Send and read plain-text messages
Adds LocalSend-compatible text messaging alongside file transfer.

Wire format (matches the LocalSend app): a message is a single "file" with
fileType "text/plain" whose content rides in the prepare-upload `preview`
field. The receiver returns an empty token set, so nothing is uploaded — the
text is read straight from the preview.

- client: SendMessage builds that single-file prepare-upload (no body upload).
- server: detect a message (one text file with non-empty preview), surface it
  on a new Messages() channel instead of saving a file, and respond with an
  empty file set. Messages bypass the accept prompt (auto-received); the PIN
  gate still applies.
- app: bridge the server's messages channel to the TUI as MessageMsg.
- tui: a new Messages tab lists received messages (enter to read full, d to
  delete); press `m` on a device to compose and send one. Incoming messages
  show a footer notice.

Tests: end-to-end send→receive (text intact, sender preserved, nothing written
to disk) and unit coverage of the message-detection rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:40:40 +01:00
6e7aac090c Stop a cancelled transfer from carrying on into the next one
When the receiver cancelled a transfer partway through, the sender kept
uploading the remaining files of that batch (the loop continued past the
failure), and a newly started transfer never stopped the old goroutine —
so the old, cancelled transfer appeared to "carry on".

Fixes:
- Each send now runs under a cancellable context. A new transfer to a peer
  supersedes (cancels) any still-running send to that same peer.
- A network/session error mid-batch aborts the rest of the batch, emitting
  a clean Cancel for the un-sent files instead of pushing them anyway. A
  local file-open error still skips only that file.
- Bounded connection timeouts (dial 10s, TLS 10s, response-header 30s) so a
  vanished peer fails fast; the overall timeout stays off for large files.

Test: a receiver whose session is gone (returns 403) causes the sender to
make exactly one upload attempt and then abort, reporting the rest cancelled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:20:30 +01:00
36 changed files with 3954 additions and 170 deletions

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
/omarchy-send /omarchy-send
/dist/omarchy-send-linux-* /dist/omarchy-send-linux-*
*.tmp *.tmp
__pycache__/
*.pyc
dist/

207
README.md
View file

@ -10,18 +10,37 @@ 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, stage files (or whole folders) with a built-in file - **Send** — pick a peer, then find what to send with a built-in **recursive
picker, and upload them with progress, rate and ETA. Folders are sent fuzzy finder**: type part of a name to match files (and folders) anywhere under
recursively with their structure preserved on the receiver. your home directory, stage them, and upload with progress, rate and ETA.
Folders are sent recursively with their structure preserved on the receiver.
- **Right-click in Nautilus** (Omarchy desktop) — "Send via Omarchy-Send" on any
file or folder opens the picker in a floating terminal with your selection
already staged; just pick a device. Installed only where Nautilus is present.
- **Messages** — send a plain-text message to a peer (LocalSend-compatible) and
read messages others send you in a dedicated Messages tab. Send the system
clipboard as a message, or copy a received one back to the clipboard (uses
`wl-clipboard`/`xclip`/`xsel`, or tmux's paste buffer when run inside tmux on
a headless box).
- **Desktop notifications** — on a graphical session (e.g. Omarchy/Hyprland with
`mako`), an incoming message or file offer raises a desktop notification via
`notify-send`, so a backgrounded receiver still gets your attention. Best-effort
and self-disabling on headless boxes; turn it off with `--no-notify` or the `n`
key in Settings.
- **Manage** — browse the receive folder, mark received files (or whole folders) - **Manage** — browse the receive folder, mark received files (or whole folders)
and delete the ones you no longer want, behind a confirmation prompt. and delete the ones you no longer want, behind a confirmation prompt.
- **HTTPS** — generates a self-signed certificate whose fingerprint matches the - **HTTPS** — generates a self-signed certificate whose fingerprint matches the
scheme the official client pins (uppercase-hex SHA-256 of the cert DER), so scheme the official client pins (uppercase-hex SHA-256 of the cert DER), so
stock encrypted peers talk to it with no configuration. stock encrypted peers talk to it with no configuration.
- **Single static binary**, pure-stdlib protocol layer; only the Charm TUI - **Single static binary**, pure-stdlib protocol layer; the only external
libraries are external dependencies. dependencies are the Charm TUI libraries and `sahilm/fuzzy` (the send finder's
matcher) — both compiled in, so headless boxes need nothing extra.
## Install ## Install
@ -32,9 +51,26 @@ 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**). Override the Omarchy also adds a floating Walker entry (search **Omarchy-Send**) and the
location with `BIN_DIR=/usr/local/bin`, or pin a version with Nautilus right-click integration. Override the location with `BIN_DIR=/usr/local/bin`,
`OMARCHY_SEND_VERSION=v0.1.0`. or pin a version with `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.
@ -60,8 +96,150 @@ omarchy-send --dir ~/Downloads # override the receive directory
omarchy-send --auto-accept # accept incoming transfers without a prompt omarchy-send --auto-accept # accept incoming transfers without a prompt
omarchy-send --pin 2468 # require senders to supply this PIN omarchy-send --pin 2468 # require senders to supply this PIN
omarchy-send --no-icons # drop Nerd Font glyphs (non-Nerd-Font terminals) omarchy-send --no-icons # drop Nerd Font glyphs (non-Nerd-Font terminals)
omarchy-send --no-notify # don't raise desktop notifications on incoming
``` ```
### Headless send (no TUI)
Send a one-off message, files, or folders to a peer by name, with no terminal
UI — handy from scripts, cron, AI agents, or an SSH session with no TTY:
```sh
omarchy-send --to "Strong Onion" --message "hello"
omarchy-send --to "Strong Onion" --message "deploy finished" --wait 20s
omarchy-send --to "Strong Onion" --message "hi" --send-pin 2468 # if the peer requires a PIN
omarchy-send --to "Strong Onion" report.pdf photos/ # files and folders
omarchy-send --to "Strong Onion" --message "build log attached" build.log
```
The target is matched against the peer's display name, case-insensitively. The
command discovers the peer over multicast and, like the TUI, directly probes
your known peers and online Tailscale peers (waiting up to `--wait`, default
15s) — so a remote box added with `+` in the TUI, or any tailnet peer, is a
valid `--to` target from a script too. It sends the message and/or files,
prints a result line per file, and exits non-zero if the peer isn't found or
the send fails. It starts discovery only — not the receiver — so it's safe to
run while another `omarchy-send` instance is up.
`--to` requires a `--message`, file/folder paths, or both (flags must come
before the paths). A folder is sent whole and its structure recreated on the
receiver. Paths *without* `--to` instead open the TUI with them pre-staged
(quick-send) — that form needs a TTY.
### Sending files
Select a device on the **Devices** tab and press `enter` to open the send
finder. It indexes files and folders under your home directory and fuzzy-matches
as you type, so you can jump straight to what you want instead of browsing
folder by folder:
- type to filter · `↑`/`↓` move · `enter` stage the highlighted file **or folder**
- `ctrl+d` show folders only (to send a whole directory) · `ctrl+s` send · `ctrl+u`
move the search root up a level · `esc` back
Staging a folder sends it whole (its structure is recreated on the receiver).
Matching is case-insensitive, and noisy directories (`.git`, `node_modules`,
caches, dotfiles…) are skipped to keep the index fast.
### Remote devices (over Tailscale)
Multicast discovery only finds peers on the same LAN. To send to / receive from a
box elsewhere, omarchy-send probes it directly over unicast — which works over
anything routable, [Tailscale](https://tailscale.com) being the easy, secure choice
(stable addresses, end-to-end encryption, no port-forwarding):
1. `tailscale up` on both devices (one-time).
2. Either let omarchy-send **auto-discover** online tailnet peers (it probes them
every few seconds; any running omarchy-send/LocalSend appears in Devices), or
press **`+`** on the Devices tab and enter a host, IP, or Tailscale name (e.g.
`colossus`). Added devices are saved to `knownPeers` in the config and re-probed
on every launch.
The receiver already listens on all interfaces, so it's reachable at its Tailscale
IP with nothing else to configure. Sending and receiving both work, because the
probe is a two-way handshake (each side learns the other).
#### Containers / userspace-networking tailscaled
When tailscaled runs with `--tun=userspace-networking` (typical in unprivileged
containers — no TUN device), processes cannot dial tailnet addresses directly;
outbound tailnet traffic only works through tailscaled's built-in SOCKS5 proxy.
omarchy-send handles this automatically: when the box has no tailnet address on
a local interface but a proxy answers at `localhost:1055`, connections to
`100.64.0.0/10` destinations are routed through it — LAN traffic is never
proxied, and explicit `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables override
the auto-detection. The one thing the box must provide is the proxy itself:
```sh
tailscaled --tun=userspace-networking --socks5-server=localhost:1055 …
```
The installer detects this situation and **offers to apply the fix** ([y/N]):
it adds the flag to the launcher script that starts tailscaled (a container
entrypoint, say) and restarts the daemon — or, when it lacks the rights to
restart it, patches the launcher and tells you to restart the container/box.
Non-interactive installs never touch the daemon; pre-answer with
`OMARCHY_SEND_FIX_TAILSCALE=yes` (or `no`) to skip the prompt.
Note that inbound connections on such boxes appear to come from `127.0.0.1`
(tailscaled re-dials loopback); omarchy-send keeps a peer's routable address
rather than letting those registers overwrite it.
#### 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)
On an Omarchy desktop, the installer adds a **"Send via Omarchy-Send"** entry to
the Nautilus context menu. Right-click one or more files or folders (multi-select
works) and choose it: a floating terminal opens with your selection pre-staged on
the device list — pick a device and it sends, then the window closes itself once
the transfer finishes.
This is a graphical convenience and is **desktop-only**: it's installed only when
Nautilus is present, so headless servers don't get it (and don't need it — use
the TUI or headless send there). Under the hood it just runs
`omarchy-send <paths…>`, which you can call yourself from any terminal.
### Theming ### Theming
On Omarchy, the TUI reads the active theme's `~/.config/omarchy/current/theme/colors.toml` On Omarchy, the TUI reads the active theme's `~/.config/omarchy/current/theme/colors.toml`
@ -91,14 +269,17 @@ omarchy-send --auto-accept --pin 2468
### Keys ### Keys
- `1`/`2`/`3`/`4` or `tab` — switch between Devices / Transfers / Manage / Settings - `1``5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings
- Peers: `enter` send to the selected peer · `r` refresh · `/` filter - Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `+` add a remote device · `r` refresh · `/` filter
- Send picker: `enter` stage a file · `a` add the current folder · `backspace` unstage · `S` send · `esc` back - PIN-protected peers: messages prompt for the PIN and retry, just like file sends
- Send finder: type to fuzzy-filter · `enter` stage file/folder · `ctrl+d` folders-only · `ctrl+s` send · `ctrl+u` up a dir · `esc` back
- Incoming prompt: `y` accept · `n` reject - Incoming prompt: `y` accept · `n` reject
- Transfers: `c` clear finished - Transfers: `c` clear finished
- Messages: `enter` read the full message · `y` copy it to the clipboard · `d`
delete it (incoming messages arrive automatically, with a footer notice)
- Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one - Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one
under the cursor) · `r` refresh · `/` filter — deletion asks to confirm first under the cursor) · `r` refresh · `/` filter — deletion asks to confirm first
- Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept - Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept · `i` toggle icons · `n` toggle notifications
- Sending to a PIN-protected peer prompts for the PIN and retries - Sending to a PIN-protected peer prompts for the PIN and retries
- `q` quit - `q` quit
@ -117,5 +298,5 @@ desktop client and are not controlled by the sender.
MIT — see [LICENSE](LICENSE). Omarchy-Send is an independent implementation of MIT — see [LICENSE](LICENSE). Omarchy-Send is an independent implementation of
the published [LocalSend protocol](https://github.com/localsend/protocol); it is the published [LocalSend protocol](https://github.com/localsend/protocol); it is
not affiliated with the LocalSend project. The terminal UI is built on the not affiliated with or endorsed by the LocalSend project. The terminal UI is built on the
[Charm](https://github.com/charmbracelet) libraries (also MIT). [Charm](https://github.com/charmbracelet) libraries (also MIT).

View file

@ -5,17 +5,28 @@ package main
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors"
"flag" "flag"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"omarchy-send/internal/app" "omarchy-send/internal/app"
"omarchy-send/internal/client" "omarchy-send/internal/client"
"omarchy-send/internal/config" "omarchy-send/internal/config"
"omarchy-send/internal/dbg"
"omarchy-send/internal/discovery" "omarchy-send/internal/discovery"
"omarchy-send/internal/notify"
"omarchy-send/internal/server" "omarchy-send/internal/server"
"omarchy-send/internal/tailscale"
"omarchy-send/internal/transfer"
"omarchy-send/internal/tui" "omarchy-send/internal/tui"
) )
@ -24,13 +35,113 @@ type controller struct {
disc *discovery.Discoverer disc *discovery.Discoverer
sender *client.Sender sender *client.Sender
srv *server.Server srv *server.Server
notify *atomic.Bool // live gate for desktop notifications (toggled from Settings)
rem *remotes // live set of directly-probed (known/remote) hosts
}
// remotes is the live set of hosts probed directly over unicast: known peers
// loaded from config plus any added at runtime in the TUI. Guarded because the
// watcher goroutine and the controller's AddKnownPeer both touch it.
type remotes struct {
mu sync.Mutex
hosts []string
}
func (r *remotes) list() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.hosts...)
}
// add appends host if not already present, returning true if it was new.
func (r *remotes) add(host string) bool {
host = strings.TrimSpace(host)
if host == "" {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
for _, h := range r.hosts {
if h == host {
return false
}
}
r.hosts = append(r.hosts, host)
return true
}
// AddKnownPeer registers a remote host and probes it immediately so it shows up
// without waiting for the next watcher tick. Persisting it to config is the
// TUI's job; this only updates the live set.
func (c controller) AddKnownPeer(host string) {
if c.rem != nil {
c.rem.add(host)
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
_ = c.disc.Probe(ctx, host)
}()
}
// watchRemotes periodically probes the known-peer set plus any online Tailscale
// peers, so devices that multicast can't reach (different subnet / over the
// tailnet) still appear in the list — and age out when they stop answering.
func watchRemotes(ctx context.Context, disc *discovery.Discoverer, rem *remotes) {
probeAll := func() {
seen := map[string]bool{}
hosts := rem.list()
hosts = append(hosts, tailscale.Peers(ctx)...)
for _, h := range hosts {
if h == "" || seen[h] {
continue
}
seen[h] = true
go func(host string) {
pctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
_ = disc.Probe(pctx, host)
}(h)
}
}
probeAll() // immediate, so remotes appear without waiting a tick
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
probeAll()
}
}
} }
func (c controller) Announce() { c.disc.Announce() } func (c controller) Announce() { c.disc.Announce() }
func (c controller) Send(p discovery.Peer, paths []string, pin string) { c.sender.Send(p, paths, pin) } func (c controller) Send(p discovery.Peer, paths []string, pin string) { c.sender.Send(p, paths, pin) }
func (c controller) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) } func (c controller) SendMessage(p discovery.Peer, text, pin string) {
func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) } c.sender.SendMessage(p, text, pin)
func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) } }
// The Set* receiver/server toggles no-op when there is no server — quick-send
// mode (Nautilus right-click) runs server-less so it can coexist with an
// already-running instance without fighting over the listen port.
func (c controller) SetAutoAccept(v bool) {
if c.srv != nil {
c.srv.SetAutoAccept(v)
}
}
func (c controller) SetPIN(pin string) {
if c.srv != nil {
c.srv.SetPIN(pin)
}
}
func (c controller) SetReceiveDir(dir string) {
if c.srv != nil {
c.srv.SetReceiveDir(dir)
}
}
func (c controller) SetNotify(v bool) { c.notify.Store(v) }
// SetAlias updates the alias across all services and re-announces it. // SetAlias updates the alias across all services and re-announces it.
func (c controller) SetAlias(alias string) { func (c controller) SetAlias(alias string) {
@ -48,9 +159,21 @@ func main() {
pinFlag = flag.String("pin", "", "require this PIN from senders (overrides config)") pinFlag = flag.String("pin", "", "require this PIN from senders (overrides config)")
autoFlag = flag.Bool("auto-accept", false, "auto-accept incoming transfers (no prompt)") autoFlag = flag.Bool("auto-accept", false, "auto-accept incoming transfers (no prompt)")
noIcons = flag.Bool("no-icons", false, "hide Nerd Font device icons (for non-Nerd-Font terminals)") noIcons = flag.Bool("no-icons", false, "hide Nerd Font device icons (for non-Nerd-Font terminals)")
noNotify = flag.Bool("no-notify", false, "don't raise desktop notifications on incoming messages/files")
// Headless one-shot send (no TUI): -to <alias> -message <text>.
toFlag = flag.String("to", "", "headless send: target peer alias to send to (no TUI); combine with -message and/or file paths")
messageFlag = flag.String("message", "", "headless send: plain-text message to send to -to")
sendPINFlag = flag.String("send-pin", "", "headless send: PIN to present if the target peer requires one")
waitFlag = flag.Duration("wait", 15*time.Second, "headless send: how long to wait for the target peer to be discovered")
) )
flag.Parse() flag.Parse()
// The TUI owns the terminal, so keep stray stdlib logging (e.g. net/http's
// "unsolicited response on idle channel" notice) off the screen — route it
// to the debug log when enabled, otherwise discard it.
log.SetOutput(dbg.Writer())
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err) fmt.Fprintf(os.Stderr, "config: %v\n", err)
@ -64,7 +187,7 @@ func main() {
cfg.Port = *portFlag cfg.Port = *portFlag
} }
if *dirFlag != "" { if *dirFlag != "" {
cfg.ReceiveDir = *dirFlag cfg.ReceiveDir = config.ExpandHome(*dirFlag)
} }
if *pinFlag != "" { if *pinFlag != "" {
cfg.PIN = *pinFlag cfg.PIN = *pinFlag
@ -75,6 +198,32 @@ func main() {
if *noIcons { if *noIcons {
cfg.NoIcons = true cfg.NoIcons = true
} }
if *noNotify {
cfg.NoNotify = true
}
// Headless one-shot send: resolve the target by alias over discovery, send
// a message and/or positional file/folder paths, and exit — no TUI, no
// terminal required. Suitable for scripts, cron, and AI agents.
if *toFlag != "" || *messageFlag != "" {
paths := absPaths(flag.Args())
if *toFlag == "" {
fmt.Fprintln(os.Stderr, "headless send needs -to <alias> (plus -message <text> and/or file paths)")
os.Exit(2)
}
if *messageFlag == "" && len(paths) == 0 {
fmt.Fprintln(os.Stderr, "headless send needs -message <text>, file/folder paths, or both")
os.Exit(2)
}
os.Exit(runHeadlessSend(cfg, *toFlag, *messageFlag, *sendPINFlag, *waitFlag, paths))
}
// Quick-send: any positional arguments are file/folder paths to send (the
// Nautilus right-click integration calls `omarchy-send <paths…>`). Open the
// TUI with them pre-staged, on the device list.
if args := flag.Args(); len(args) > 0 {
os.Exit(runQuickSend(cfg, absPaths(args)))
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -109,11 +258,25 @@ func main() {
} }
sender := client.New(cfg.DeviceInfo()) sender := client.New(cfg.DeviceInfo())
ctrl := controller{disc: disc, sender: sender, srv: srv}
// notifyOn is the live notification gate: the user's preference (-no-notify /
// the Settings toggle), AND only meaningful where notify-send can actually
// reach a daemon. notify.Send itself no-ops when unavailable, so the gate
// only carries the user preference here.
notifyOn := &atomic.Bool{}
notifyOn.Store(!cfg.NoNotify)
rem := &remotes{hosts: cfg.KnownPeers}
ctrl := controller{disc: disc, sender: sender, srv: srv, notify: notifyOn, rem: rem}
go watchRemotes(ctx, disc, rem)
p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen()) p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen())
app.BridgeDiscovery(ctx, disc.Events(), p.Send) app.BridgeDiscovery(ctx, disc.Events(), p.Send)
app.BridgeServer(ctx, srv.Accepts(), srv.Transfers(), p.Send) notifyFn := func(summary, body string) {
if notifyOn.Load() {
notify.Send(summary, body)
}
}
app.BridgeServer(ctx, srv.Accepts(), srv.Transfers(), srv.Messages(), p.Send, notifyFn)
app.BridgeTransfers(ctx, sender.Events(), p.Send) app.BridgeTransfers(ctx, sender.Events(), p.Send)
disc.Announce() // announce immediately so we appear without waiting a tick disc.Announce() // announce immediately so we appear without waiting a tick
@ -122,3 +285,142 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
// runQuickSend opens the TUI on the device list with paths pre-staged, so the
// user just picks a recipient. It runs server-less (discovery + sender only),
// like runHeadlessSend, so it coexists with an already-running receiver instead
// of crashing on the busy listen port.
func runQuickSend(cfg config.Config, paths []string) int {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
disc := discovery.New(cfg.DeviceInfo())
if err := disc.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "discovery: %v\n", err)
return 1
}
sender := client.New(cfg.DeviceInfo())
// No receiver in quick-send mode, so nothing to notify about.
notifyOff := &atomic.Bool{}
rem := &remotes{hosts: cfg.KnownPeers}
ctrl := controller{disc: disc, sender: sender, srv: nil, notify: notifyOff, rem: rem}
go watchRemotes(ctx, disc, rem) // so a remote box is a valid quick-send target too
p := tea.NewProgram(tui.New(cfg, ctrl, tui.WithStagedFiles(paths)), tea.WithAltScreen())
app.BridgeDiscovery(ctx, disc.Events(), p.Send)
app.BridgeTransfers(ctx, sender.Events(), p.Send)
disc.Announce() // solicit replies immediately rather than waiting a tick
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
return 1
}
return 0
}
// absPaths resolves each path to absolute (best-effort; a path that fails to
// resolve is passed through as-is and will fail with a clear error later).
func absPaths(args []string) []string {
paths := make([]string, 0, len(args))
for _, a := range args {
if abs, err := filepath.Abs(a); err == nil {
paths = append(paths, abs)
} else {
paths = append(paths, a)
}
}
return paths
}
// runHeadlessSend discovers the peer whose alias matches target (case-
// insensitively), sends it the given file/folder paths and/or a plain-text
// message, and returns a process exit code. It deliberately starts only
// discovery — not the HTTP receiver — so it can run alongside an
// already-running instance without fighting over the listen port. Status goes
// to stderr; the success lines go to stdout.
func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait time.Duration, paths []string) int {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
disc := discovery.New(cfg.DeviceInfo())
if err := disc.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "discovery: %v\n", err)
return 1
}
disc.Announce() // solicit replies immediately rather than waiting a tick
// Multicast can't cross subnets or the tailnet, so also probe known peers
// and online Tailscale peers directly — same as the TUI's device list.
rem := &remotes{hosts: cfg.KnownPeers}
go watchRemotes(ctx, disc, rem)
want := strings.TrimSpace(target)
fmt.Fprintf(os.Stderr, "Looking for %q on the network (up to %s)…\n", want, wait)
findCtx, findCancel := context.WithTimeout(ctx, wait)
defer findCancel()
peer, err := disc.FindPeer(findCtx, func(p discovery.Peer) bool {
return strings.EqualFold(strings.TrimSpace(p.Info.Alias), want)
})
if err != nil {
fmt.Fprintf(os.Stderr, "no peer named %q found within %s.\n", want, wait)
if seen := disc.Snapshot(); len(seen) > 0 {
fmt.Fprintln(os.Stderr, "Peers seen:")
for _, p := range seen {
fmt.Fprintf(os.Stderr, " - %q (%s)\n", p.Info.Alias, p.IP)
}
} else {
fmt.Fprintln(os.Stderr, "No peers were seen at all — check the target is running omarchy-send / LocalSend on the same LAN, or is reachable as a known peer / over Tailscale.")
}
return 1
}
sender := client.New(cfg.DeviceInfo())
reportErr := func(err error) {
switch {
case errors.Is(err, transfer.ErrPinRequired):
fmt.Fprintf(os.Stderr, "%q requires a PIN — pass it with -send-pin.\n", peer.Info.Alias)
default:
fmt.Fprintf(os.Stderr, "send to %q (%s) failed: %v\n", peer.Info.Alias, peer.IP, err)
}
}
if len(paths) > 0 {
fmt.Fprintf(os.Stderr, "Sending to %q (%s)… (waiting for the peer to accept)\n", peer.Info.Alias, peer.IP)
sent := 0
err := sender.SendFilesSync(ctx, peer, paths, sendPIN, func(name string, size int64) {
sent++
fmt.Printf(" sent %s (%s)\n", name, humanBytes(size))
})
if err != nil {
reportErr(err)
return 1
}
fmt.Printf("%d file(s) sent to %q (%s).\n", sent, peer.Info.Alias, peer.IP)
}
if message != "" {
if err := sender.SendMessageSync(peer, message, sendPIN); err != nil {
reportErr(err)
return 1
}
fmt.Printf("Message sent to %q (%s).\n", peer.Info.Alias, peer.IP)
}
return 0
}
// humanBytes renders a byte count as a short human-readable size.
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

3
go.mod
View file

@ -6,6 +6,7 @@ require (
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/sahilm/fuzzy v0.1.1
) )
require ( require (
@ -19,7 +20,6 @@ require (
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@ -29,7 +29,6 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.3.8 // indirect

2
go.sum
View file

@ -28,8 +28,6 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=

View file

@ -14,11 +14,15 @@
# 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:
# - Headless system: installs the plain `omarchy-send` TUI binary. # - Local machine (home/LAN): installs the TUI; on Omarchy also adds a Walker
# - Omarchy desktop: additionally adds a Walker entry that launches it as a # entry + the Nautilus right-click integration.
# floating TUI (via the stock TUI.float app-id), like the Wi-Fi TUI. # - Remote server (public IP): same install, then restricts port 53317 to the
# Tailscale interface in the firewall, so it's reachable over the tailnet
# only — not the open internet.
set -euo pipefail set -euo pipefail
@ -27,6 +31,7 @@ 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"
@ -36,6 +41,30 @@ 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..."
@ -109,10 +138,441 @@ Keywords=localsend;share;transfer;airdrop;
EOF EOF
echo "==> Omarchy detected — added floating Walker entry (with icon)." echo "==> Omarchy detected — added floating Walker entry (with icon)."
echo " Launch it from Walker by searching 'Omarchy-Send'." echo " Launch it from Walker by searching 'Omarchy-Send'."
# Nautilus right-click: "Send via Omarchy-Send". Same approach as Omarchy's
# Transcode entry — a nautilus-python MenuProvider that opens the TUI in a
# floating presentation terminal with the selected paths pre-staged. Installed
# from the clone when present, otherwise written from an embedded copy so the
# curl-piped install needs nothing extra.
#
# Desktop-only: skip entirely when Nautilus isn't installed (e.g. a headless
# server). The right-click flow is a graphical convenience and is not required
# there — the TUI and headless send still work without it.
if command -v nautilus >/dev/null 2>&1; then
ext_dir="$HOME/.local/share/nautilus-python/extensions"
mkdir -p "$ext_dir"
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/nautilus/omarchy-send.py" ]; then
cp "$SCRIPT_DIR/nautilus/omarchy-send.py" "$ext_dir/omarchy-send.py"
else
cat > "$ext_dir/omarchy-send.py" <<'PY'
import os
import shlex
import shutil
from gi import require_version
require_version("Nautilus", "4.1")
from gi.repository import GObject, Gio, Nautilus
# omarchy-send installs to ~/.local/bin, which is often NOT on the PATH the
# Nautilus process (and the terminal it spawns) inherits from the graphical
# session — so resolve it to an absolute path and invoke it by that.
def _resolve(name, fallbacks):
found = shutil.which(name)
if found:
return found
for path in fallbacks:
if path and os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def _binary():
home = os.path.expanduser("~")
fallbacks = []
bin_dir = os.environ.get("BIN_DIR")
if bin_dir:
fallbacks.append(os.path.join(bin_dir, "omarchy-send"))
fallbacks.append(os.path.join(home, ".local", "bin", "omarchy-send"))
fallbacks.append(os.path.join(home, "bin", "omarchy-send"))
return _resolve("omarchy-send", fallbacks)
def _wrapper():
home = os.path.expanduser("~")
fallbacks = [
os.path.join(home, ".local", "share", "omarchy", "bin",
"omarchy-launch-floating-terminal-with-presentation"),
]
return _resolve("omarchy-launch-floating-terminal-with-presentation", fallbacks)
class OmarchySendAction(GObject.GObject, Nautilus.MenuProvider):
def _launch(self, paths):
wrapper = _wrapper()
binary = _binary()
if not wrapper or not binary:
return
cmd = shlex.join([binary, *paths])
Gio.Subprocess.new([wrapper, cmd], Gio.SubprocessFlags.NONE)
def _selected_paths(self, files):
paths = []
seen = set()
for file in files:
location = file.get_location()
if not location:
continue
path = location.get_path()
if path and path not in seen:
seen.add(path)
paths.append(path)
return paths
def _make_item(self, paths):
label = (
"Send via Omarchy-Send"
if len(paths) == 1
else f"Send {len(paths)} items via Omarchy-Send"
)
item = Nautilus.MenuItem(
name="OmarchySendNautilus::send",
label=label,
icon="omarchy-send",
)
item.connect("activate", self._on_activate, paths)
return item
def _on_activate(self, _menu, paths):
self._launch(paths)
def get_file_items(self, *args):
files = args[0] if len(args) == 1 else args[1]
if not _wrapper() or not _binary():
return []
paths = self._selected_paths(files)
if not paths:
return []
return [self._make_item(paths)]
PY
fi
echo "==> Added Nautilus right-click entry 'Send via Omarchy-Send'."
# Restart Nautilus so the extension loads (windows reopen on demand).
nautilus -q >/dev/null 2>&1 || true
fi # nautilus present
else 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 || true)"
[ -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.
**Shorthand:** "OSF" means omarchy-send. When the user says e.g. "OSF
report.pdf to gav" or "OSF that over to the laptop", run the headless CLI send
described below — `omarchy-send -to "<alias>" <paths…>`.
**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.
**You can SEND messages AND files from the CLI** — no TUI, no TTY, works from
scripts and agents. To message another device (e.g. to notify the user on
their desktop), or to send files/folders to it:
omarchy-send -to "<device alias>" -message "<text>"
omarchy-send -to "<device alias>" <file-or-folder>…
omarchy-send -to "<device alias>" -message "<text>" <file>…
Flags must come before the paths. A folder is sent whole (structure recreated
on the receiver). Add `-send-pin <pin>` if the target requires a PIN, and
`-wait 30s` to allow longer for discovery. Works over the LAN and Tailscale
alike; exit code 0 means delivered. The receiving device must be running its
receiver (this TUI, or LocalSend) and may prompt its user to accept. Paths
*without* `-to` open the TUI instead, which needs a TTY.
**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). **You can SEND messages and files to another device from the CLI**
(no TUI/TTY, fine for scripts and agents):
`omarchy-send -to "<alias>" -message "<text>"` and/or
`omarchy-send -to "<alias>" <file-or-folder>…` (flags before paths; add
`-send-pin <pin>` if the target requires one); exit 0 = delivered. Works
over LAN and Tailscale. **"OSF" is user shorthand for omarchy-send** — "OSF
<file> to <alias>" means run that CLI send. 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 || true)"
# 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
# ---- userspace-networking Tailscale: outbound proxy check -----------------
# Under tailscaled --tun=userspace-networking (no TUN device — the default in
# unprivileged containers), processes cannot dial OUT to tailnet addresses at
# all; tailscaled's SOCKS5 proxy is the only outbound path. omarchy-send
# auto-detects and uses it at localhost:1055 — so check it's there and say
# exactly what to do when it isn't (receive works either way; send doesn't).
if command -v tailscale >/dev/null 2>&1 && [ -z "$TS_IFACE" ] \
&& tailscale status >/dev/null 2>&1; then
if (exec 3<>/dev/tcp/127.0.0.1/1055) 2>/dev/null; then
exec 3>&- || true
echo
echo "==> Userspace-networking Tailscale detected; SOCKS5 proxy found at"
echo " localhost:1055 — sends to tailnet devices will route through it"
echo " automatically. Nothing to do."
else
echo
echo "⚠ Tailscale is running in userspace-networking mode (no TUN interface)"
echo " and no SOCKS5 proxy is listening on localhost:1055. This box can"
echo " RECEIVE over the tailnet, but CANNOT SEND to tailnet devices until"
echo " tailscaled runs with its proxy enabled."
# Offer to apply the fix: add --socks5-server=localhost:1055 to whatever
# launcher starts tailscaled (e.g. a container entrypoint) and restart it.
# Defaults to NO — non-interactive installs never restart someone else's
# daemon. OMARCHY_SEND_FIX_TAILSCALE=yes|no skips the prompt.
FIX_TS="${OMARCHY_SEND_FIX_TAILSCALE:-}"
case "$FIX_TS" in yes | no) : ;; *)
FIX_TS="no"
if { exec 3<>/dev/tty; } 2>/dev/null; then
printf ' Enable it now? The tailscaled launcher gets the flag added and\n tailscaled is restarted — this briefly drops the tailnet, including\n any tailscale SSH session. [y/N] ' >&3 || true
IFS= read -r _fx <&3 || _fx=""
exec 3>&- 3<&- || true
case "$_fx" in y | Y | yes | Yes | YES) FIX_TS="yes" ;; esac
fi
;;
esac
if [ "$FIX_TS" = "yes" ]; then
# 1. Persist: patch every writable launcher script that starts tailscaled
# in userspace mode (idempotent — skips ones already carrying the flag).
PATCHED=""
while IFS= read -r _f; do
[ -n "$_f" ] || continue
if grep -q "socks5-server" "$_f"; then PATCHED="$_f"; continue; fi
if [ -w "$_f" ] &&
sed -i "s|--tun=userspace-networking|--tun=userspace-networking --socks5-server=localhost:1055|" "$_f" 2>/dev/null; then
PATCHED="$_f"
echo " Patched launcher: $_f"
fi
done < <(grep -rlse '--tun=userspace-networking' \
"$HOME/.local/bin" /usr/local/bin /usr/local/sbin 2>/dev/null || true)
[ -z "$PATCHED" ] &&
echo " No writable tailscaled launcher found — the restart below won't survive a reboot."
# 2. Restart tailscaled now with its current flags + the proxy. Needs the
# rights of whoever owns the daemon (root in most containers).
RESTARTED=0
_pid="$(pgrep -x tailscaled | head -n1 || true)"
if [ -n "$_pid" ] && mapfile -d '' _args <"/proc/$_pid/cmdline" 2>/dev/null &&
[ "${#_args[@]}" -gt 0 ]; then
case " ${_args[*]} " in *socks5-server*) : ;; *) _args+=("--socks5-server=localhost:1055") ;; esac
SUDO=""
[ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
if [ -z "$SUDO" ] || sudo -n true 2>/dev/null; then
$SUDO pkill -x tailscaled 2>/dev/null || true
sleep 1
# shellcheck disable=SC2086 # $SUDO is deliberately word-split (empty or "sudo -n")
($SUDO nohup "${_args[@]}" >"${TMPDIR:-/tmp}/tailscaled-restart.log" 2>&1 &)
sleep 3
if (exec 3<>/dev/tcp/127.0.0.1/1055) 2>/dev/null; then RESTARTED=1; fi
fi
fi
if [ "$RESTARTED" = "1" ]; then
echo " Done — SOCKS5 proxy is up. Tailnet sends now work, no env vars needed"
[ -n "$PATCHED" ] && echo " (and the patched launcher keeps it working across restarts)."
elif [ -n "$PATCHED" ]; then
echo " Launcher patched, but tailscaled couldn't be restarted from here"
echo " (needs root). Restart the container/box and the fix applies itself."
else
echo " Couldn't patch or restart automatically. Add this flag wherever"
echo " tailscaled is launched, keeping its existing flags:"
echo " tailscaled --tun=userspace-networking --socks5-server=localhost:1055 …"
fi
else
echo " Skipped. To fix manually, add this flag wherever tailscaled is"
echo " launched (keeping its existing --state/--socket flags):"
echo " tailscaled --tun=userspace-networking --socks5-server=localhost:1055 …"
echo " omarchy-send then uses the proxy automatically — no env vars needed."
echo " (Or re-run the installer with OMARCHY_SEND_FIX_TAILSCALE=yes.)"
fi
fi
fi
echo echo
case ":$PATH:" in case ":$PATH:" in
*":$BIN_DIR:"*) : ;; *":$BIN_DIR:"*) : ;;

View file

@ -5,6 +5,7 @@ package app
import ( import (
"context" "context"
"fmt"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -26,23 +27,61 @@ type IncomingMsg struct{ Req server.AcceptRequest }
// TransferMsg reports progress/lifecycle of a transfer (either direction). // TransferMsg reports progress/lifecycle of a transfer (either direction).
type TransferMsg struct{ Ev transfer.Event } type TransferMsg struct{ Ev transfer.Event }
// BridgeServer forwards the server's accept requests and transfer events to the // MessageMsg is delivered when a peer sends us a plain-text message.
// Tea program until ctx is cancelled. type MessageMsg struct{ Msg server.ReceivedMessage }
func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, transfers <-chan transfer.Event, send func(tea.Msg)) {
// BridgeServer forwards the server's accept requests, transfer events, and
// received messages to the Tea program until ctx is cancelled. notify, if
// non-nil, is called (summary, body) for events worth a desktop notification —
// an inbound message or a peer offering files — so a backgrounded receiver
// surfaces them through the desktop's notification daemon.
func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, transfers <-chan transfer.Event, messages <-chan server.ReceivedMessage, send func(tea.Msg), notify func(summary, body string)) {
go func() { go func() {
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case req := <-accepts: case req := <-accepts:
if notify != nil {
notify(notifyTitleFrom(req.From.Alias)+" wants to send files", incomingFilesBody(req))
}
send(IncomingMsg{Req: req}) send(IncomingMsg{Req: req})
case ev := <-transfers: case ev := <-transfers:
send(TransferMsg{Ev: ev}) send(TransferMsg{Ev: ev})
case m := <-messages:
if notify != nil {
notify("Message from "+nonEmptyAlias(m.From), m.Text)
}
send(MessageMsg{Msg: m})
} }
} }
}() }()
} }
// nonEmptyAlias returns alias, or a generic stand-in when a peer sent no name.
func nonEmptyAlias(alias string) string {
if alias == "" {
return "a device"
}
return alias
}
func notifyTitleFrom(alias string) string { return nonEmptyAlias(alias) }
// incomingFilesBody summarises an incoming file offer for a notification body,
// naming the file when there's only one and counting them otherwise.
func incomingFilesBody(req server.AcceptRequest) string {
switch len(req.Files) {
case 0:
return "Incoming transfer"
case 1:
for _, f := range req.Files {
return f.FileName
}
}
return fmt.Sprintf("%d files", len(req.Files))
}
// BridgeTransfers forwards a transfer event channel (e.g. the sender's) to the // BridgeTransfers forwards a transfer event channel (e.g. the sender's) to the
// Tea program until ctx is cancelled. // Tea program until ctx is cancelled.
func BridgeTransfers(ctx context.Context, transfers <-chan transfer.Event, send func(tea.Msg)) { func BridgeTransfers(ctx context.Context, transfers <-chan transfer.Event, send func(tea.Msg)) {

View file

@ -0,0 +1,70 @@
package app
import (
"context"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"omarchy-send/internal/protocol"
"omarchy-send/internal/server"
"omarchy-send/internal/transfer"
)
// An inbound message must drive the notify callback with the sender's name and
// the message text, so a backgrounded receiver surfaces it on the desktop.
func TestBridgeServerNotifiesOnMessage(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
accepts := make(chan server.AcceptRequest)
transfers := make(chan transfer.Event)
messages := make(chan server.ReceivedMessage, 1)
type call struct{ summary, body string }
got := make(chan call, 1)
notify := func(summary, body string) { got <- call{summary, body} }
BridgeServer(ctx, accepts, transfers, messages, func(tea.Msg) {}, notify)
messages <- server.ReceivedMessage{From: "Strong Onion", Text: "dinner's ready"}
select {
case c := <-got:
if c.summary != "Message from Strong Onion" {
t.Errorf("summary = %q", c.summary)
}
if c.body != "dinner's ready" {
t.Errorf("body = %q", c.body)
}
case <-time.After(time.Second):
t.Fatal("notify was not called for an inbound message")
}
}
// incomingFilesBody names a single file and counts multiple ones.
func TestIncomingFilesBody(t *testing.T) {
one := server.AcceptRequest{Files: map[string]protocol.FileMetadata{
"a": {FileName: "photo.jpg"},
}}
if got := incomingFilesBody(one); got != "photo.jpg" {
t.Errorf("single file body = %q, want photo.jpg", got)
}
many := server.AcceptRequest{Files: map[string]protocol.FileMetadata{
"a": {FileName: "x"}, "b": {FileName: "y"}, "c": {FileName: "z"},
}}
if got := incomingFilesBody(many); got != "3 files" {
t.Errorf("multi file body = %q, want \"3 files\"", got)
}
}
func TestNonEmptyAlias(t *testing.T) {
if got := nonEmptyAlias(""); got != "a device" {
t.Errorf("empty alias = %q", got)
}
if got := nonEmptyAlias("Slate Starburst"); got != "Slate Starburst" {
t.Errorf("alias = %q", got)
}
}

View file

@ -0,0 +1,94 @@
package client
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"time"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
"omarchy-send/internal/transfer"
)
// When the receiver cancels mid-batch (its session is gone, so /upload returns
// 403), the sender must stop pushing the remaining files rather than carry the
// old transfer on. Regression test for "a cancelled transfer carries on".
func TestSendAbortsBatchWhenSessionGone(t *testing.T) {
var uploads int32
mux := http.NewServeMux()
mux.HandleFunc(protocol.PathPrepareUpload, func(w http.ResponseWriter, r *http.Request) {
var req protocol.PrepareUploadRequest
_ = json.NewDecoder(r.Body).Decode(&req)
tokens := make(map[string]string, len(req.Files))
for id := range req.Files {
tokens[id] = "tok-" + id
}
_ = json.NewEncoder(w).Encode(protocol.PrepareUploadResponse{SessionID: "s1", Files: tokens})
})
mux.HandleFunc(protocol.PathUpload, func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&uploads, 1)
http.Error(w, "forbidden", http.StatusForbidden) // session cancelled on remote
})
ts := httptest.NewServer(mux)
defer ts.Close()
u, _ := url.Parse(ts.URL)
srcDir := t.TempDir()
var paths []string
for _, n := range []string{"a.bin", "b.bin", "c.bin"} {
p := filepath.Join(srcDir, n)
if err := os.WriteFile(p, []byte("payload-"+n), 0o644); err != nil {
t.Fatal(err)
}
paths = append(paths, p)
}
host, portStr, err := net.SplitHostPort(u.Host)
if err != nil {
t.Fatalf("split %q: %v", u.Host, err)
}
port, _ := strconv.Atoi(portStr)
sender := New(protocol.DeviceInfo{Alias: "snd", Protocol: "http"})
peer := discovery.Peer{Info: protocol.DeviceInfo{Protocol: "http", Port: port}, IP: host}
sender.Send(peer, paths, "")
// Drain events until things go quiet.
errs, cancels := 0, 0
timeout := time.After(3 * time.Second)
for done := false; !done; {
select {
case ev := <-sender.Events():
switch ev.Kind {
case transfer.Error:
errs++
case transfer.Cancel:
cancels++
}
case <-time.After(300 * time.Millisecond):
done = true
case <-timeout:
done = true
}
}
if got := atomic.LoadInt32(&uploads); got != 1 {
t.Fatalf("expected exactly 1 upload attempt before aborting, got %d", got)
}
// One file errored; the other two should be reported as cancelled, not retried.
if errs != 1 {
t.Errorf("expected 1 error event, got %d", errs)
}
if cancels != 2 {
t.Errorf("expected 2 cancel events for the un-sent files, got %d", cancels)
}
}

View file

@ -21,19 +21,33 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time"
"omarchy-send/internal/dbg" "omarchy-send/internal/dbg"
"omarchy-send/internal/discovery" "omarchy-send/internal/discovery"
"omarchy-send/internal/protocol" "omarchy-send/internal/protocol"
"omarchy-send/internal/transfer" "omarchy-send/internal/transfer"
"omarchy-send/internal/tsproxy"
) )
// errOpen wraps a failure to open a source file, so the send loop can skip just
// that file rather than aborting the whole batch (which it does for peer/network
// errors, where the shared session is dead).
var errOpen = errors.New("open source file")
// inflight tracks one running send so it can be cancelled — e.g. when a newer
// transfer to the same peer supersedes it.
type inflight struct {
cancel context.CancelFunc
}
// Sender uploads files to peers. Events are delivered on Events(). // Sender uploads files to peers. Events are delivered on Events().
type Sender struct { type Sender struct {
mu sync.Mutex mu sync.Mutex
self protocol.DeviceInfo self protocol.DeviceInfo
http *http.Client http *http.Client
events chan transfer.Event events chan transfer.Event
active map[string]*inflight // in-flight sends keyed by peer IP
} }
// New returns a Sender advertising self. TLS chain validation is disabled (we // New returns a Sender advertising self. TLS chain validation is disabled (we
@ -42,10 +56,23 @@ func New(self protocol.DeviceInfo) *Sender {
return &Sender{ return &Sender{
self: self, self: self,
http: &http.Client{ http: &http.Client{
Timeout: 0, // large files: no overall timeout // No overall timeout (large files), but bound the parts that can
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, // silently wedge on a vanished peer: connecting, the TLS handshake,
// and waiting for response headers after the body is sent.
Timeout: 0,
Transport: &http.Transport{
// Proxy env vars + tailnet SOCKS5 auto-detection (see
// discovery) so transfers also work from userspace-networking
// Tailscale boxes.
Proxy: tsproxy.ProxyFunc,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
},
}, },
events: make(chan transfer.Event, 256), events: make(chan transfer.Event, 256),
active: make(map[string]*inflight),
} }
} }
@ -72,7 +99,133 @@ func (s *Sender) Send(peer discovery.Peer, paths []string, pin string) {
go s.send(peer, paths, pin) go s.send(peer, paths, pin)
} }
// SendMessage sends a plain-text message to peer (LocalSend "send message":
// one text file whose content rides in the preview field, so nothing is
// uploaded). pin may be empty; supply it when the peer requires one. Errors —
// including ErrPinRequired — are reported on Events() as an outgoing Error.
func (s *Sender) SendMessage(peer discovery.Peer, text, pin string) {
go s.sendMessage(peer, text, pin)
}
func (s *Sender) sendMessage(peer discovery.Peer, text, pin string) {
if err := s.SendMessageSync(peer, text, pin); err != nil {
dbg.Logf("send message to %s failed: %v", peer.IP, err)
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: "message", Err: err})
}
}
// SendMessageSync sends a plain-text message to peer and blocks until the peer
// has accepted it (or an error occurs), returning that error directly —
// including transfer.ErrPinRequired when the peer needs a PIN. Unlike
// SendMessage it reports nothing on Events(); it exists for the headless
// one-shot send path, where there is no TUI to consume events.
func (s *Sender) SendMessageSync(peer discovery.Peer, text, pin string) error {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
id := randID()
files := map[string]protocol.FileMetadata{
id: {
ID: id,
FileName: "message.txt",
Size: int64(len(text)),
FileType: "text/plain",
Preview: text,
},
}
_, err := s.prepareUpload(ctx, s.url(peer), files, pin)
return err
}
// SendFilesSync uploads the given file/folder paths to peer and blocks until
// the whole batch is done, returning the error directly — including
// transfer.ErrPinRequired when the peer needs a PIN. Unlike Send it reports
// nothing on Events(); it exists for the headless one-shot send path, where
// there is no TUI to consume events (the progress events uploadFile emits are
// harmlessly dropped). onDone, when non-nil, is called after each file lands,
// so the CLI can print per-file progress lines.
func (s *Sender) SendFilesSync(ctx context.Context, peer discovery.Peer, paths []string, pin string, onDone func(name string, size int64)) error {
// Explicitly named paths that don't exist are a hard error up front — in
// the TUI a stat failure is just an event on one staged entry, but a script
// passing a wrong path wants a non-zero exit, not a silent skip.
for _, p := range paths {
if _, err := os.Stat(p); err != nil {
return err
}
}
items := s.expand(paths)
if len(items) == 0 {
return errors.New("nothing to send: no readable files under the given paths")
}
files := make(map[string]protocol.FileMetadata, len(items))
pathByID := make(map[string]string, len(items))
for _, it := range items {
id := randID()
files[id] = protocol.FileMetadata{
ID: id,
FileName: it.name,
Size: it.size,
FileType: mimeType(it.path),
}
pathByID[id] = it.path
}
base := s.url(peer)
prepResp, err := s.prepareUpload(ctx, base, files, pin)
if err != nil {
return err
}
// An empty token map (204) means the peer accepted but wants nothing
// uploaded — e.g. it already has the files. That's success: the loop below
// simply finds no tokens to push.
var skipped []string
for id, token := range prepResp.Files {
meta := files[id]
key := prepResp.SessionID + ":" + id
if err := s.uploadFile(ctx, base, prepResp.SessionID, id, token, key, pathByID[id], meta); err != nil {
// A failure to open a local file is specific to that file (it
// vanished or lost permissions since staging) — skip it and keep
// the batch going, like the TUI path does. Anything else means the
// peer/session is gone and can't be resumed, so abort the batch.
if errors.Is(err, errOpen) {
skipped = append(skipped, meta.FileName)
continue
}
return fmt.Errorf("upload %q: %w", meta.FileName, err)
}
if onDone != nil {
onDone(meta.FileName, meta.Size)
}
}
if len(skipped) > 0 {
return fmt.Errorf("could not read: %s", strings.Join(skipped, ", "))
}
return nil
}
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) { func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
// A new transfer to a peer supersedes any still-running one to the same
// peer: cancel it so a half-finished old batch can't carry on once the user
// starts something new.
ctx, cancel := context.WithCancel(context.Background())
h := &inflight{cancel: cancel}
s.mu.Lock()
if prev := s.active[peer.IP]; prev != nil {
prev.cancel()
}
s.active[peer.IP] = h
s.mu.Unlock()
defer func() {
cancel()
s.mu.Lock()
if s.active[peer.IP] == h {
delete(s.active, peer.IP)
}
s.mu.Unlock()
}()
// Expand any directories into their files, then build metadata keyed by a // Expand any directories into their files, then build metadata keyed by a
// generated fileId. A directory's files carry a relative FileName (e.g. // generated fileId. A directory's files carry a relative FileName (e.g.
// "Trip/day1/img.jpg") so the receiver can recreate the folder structure. // "Trip/day1/img.jpg") so the receiver can recreate the folder structure.
@ -97,7 +250,7 @@ func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
dbg.Logf("SEND prepare-upload to %s: files=%s", peer.IP, string(meta)) dbg.Logf("SEND prepare-upload to %s: files=%s", peer.IP, string(meta))
} }
base := s.url(peer) base := s.url(peer)
prepResp, err := s.prepareUpload(base, files, pin) prepResp, err := s.prepareUpload(ctx, base, files, pin)
if err != nil { if err != nil {
dbg.Logf("send prepare-upload to %s failed: %v", peer.IP, err) dbg.Logf("send prepare-upload to %s failed: %v", peer.IP, err)
if errors.Is(err, transfer.ErrPinRequired) { if errors.Is(err, transfer.ErrPinRequired) {
@ -114,12 +267,31 @@ func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
for id, token := range prepResp.Files { for id, token := range prepResp.Files {
meta := files[id] meta := files[id]
key := prepResp.SessionID + ":" + id key := prepResp.SessionID + ":" + id
if err := s.uploadFile(base, prepResp.SessionID, id, token, key, pathByID[id], meta); err != nil {
dbg.Logf("send upload %q failed: %v", meta.FileName, err) // If the transfer was cancelled (superseded, or aborted after an earlier
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, ID: key, FileName: meta.FileName, Err: err}) // failure), don't push the rest of the batch — report a clean cancel.
if ctx.Err() != nil {
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Cancel, ID: key, FileName: meta.FileName})
continue continue
} }
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.FileDone, ID: key, FileName: meta.FileName, Received: meta.Size, Total: meta.Size})
err := s.uploadFile(ctx, base, prepResp.SessionID, id, token, key, pathByID[id], meta)
if err == nil {
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.FileDone, ID: key, FileName: meta.FileName, Received: meta.Size, Total: meta.Size})
continue
}
dbg.Logf("send upload %q failed: %v", meta.FileName, err)
if ctx.Err() != nil {
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Cancel, ID: key, FileName: meta.FileName})
} else {
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, ID: key, FileName: meta.FileName, Err: err})
}
// A failure to open a local file is specific to that file — skip it and
// keep going. Any other failure means the peer/session is gone, so abort
// the rest of the batch (the shared session can't be resumed).
if !errors.Is(err, errOpen) {
cancel()
}
} }
} }
@ -174,13 +346,18 @@ func (s *Sender) expand(paths []string) []fileItem {
return items return items
} }
func (s *Sender) prepareUpload(base string, files map[string]protocol.FileMetadata, pin string) (protocol.PrepareUploadResponse, error) { func (s *Sender) prepareUpload(ctx context.Context, base string, files map[string]protocol.FileMetadata, pin string) (protocol.PrepareUploadResponse, error) {
reqBody, _ := json.Marshal(protocol.PrepareUploadRequest{Info: s.selfCopy(), Files: files}) reqBody, _ := json.Marshal(protocol.PrepareUploadRequest{Info: s.selfCopy(), Files: files})
url := base + protocol.PathPrepareUpload url := base + protocol.PathPrepareUpload
if pin != "" { if pin != "" {
url += "?pin=" + neturl.QueryEscape(pin) url += "?pin=" + neturl.QueryEscape(pin)
} }
resp, err := s.http.Post(url, "application/json", bytes.NewReader(reqBody)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
if err != nil {
return protocol.PrepareUploadResponse{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.http.Do(req)
if err != nil { if err != nil {
return protocol.PrepareUploadResponse{}, err return protocol.PrepareUploadResponse{}, err
} }
@ -188,6 +365,14 @@ func (s *Sender) prepareUpload(base string, files map[string]protocol.FileMetada
if resp.StatusCode == http.StatusUnauthorized { if resp.StatusCode == http.StatusUnauthorized {
return protocol.PrepareUploadResponse{}, transfer.ErrPinRequired return protocol.PrepareUploadResponse{}, transfer.ErrPinRequired
} }
if resp.StatusCode == http.StatusNoContent {
// 204: accepted, but nothing to upload — the official LocalSend client
// returns this for a message (its text rode in the preview field) and
// for files the peer already has. No session/token map follows, so
// return an empty response: a message send is done, and a file send
// simply finds no tokens to push, which is the correct outcome.
return protocol.PrepareUploadResponse{}, nil
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return protocol.PrepareUploadResponse{}, fmt.Errorf("prepare-upload status %d", resp.StatusCode) return protocol.PrepareUploadResponse{}, fmt.Errorf("prepare-upload status %d", resp.StatusCode)
} }
@ -198,10 +383,10 @@ func (s *Sender) prepareUpload(base string, files map[string]protocol.FileMetada
return pr, nil return pr, nil
} }
func (s *Sender) uploadFile(base, sessionID, fileID, token, key, path string, meta protocol.FileMetadata) error { func (s *Sender) uploadFile(ctx context.Context, base, sessionID, fileID, token, key, path string, meta protocol.FileMetadata) error {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return err return fmt.Errorf("%w: %v", errOpen, err)
} }
defer f.Close() defer f.Close()
@ -216,7 +401,7 @@ func (s *Sender) uploadFile(base, sessionID, fileID, token, key, path string, me
} }
url := fmt.Sprintf("%s%s?sessionId=%s&fileId=%s&token=%s", base, protocol.PathUpload, sessionID, fileID, token) url := fmt.Sprintf("%s%s?sessionId=%s&fileId=%s&token=%s", base, protocol.PathUpload, sessionID, fileID, token)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, pr) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pr)
if err != nil { if err != nil {
return err return err
} }

View file

@ -0,0 +1,128 @@
package client
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
"omarchy-send/internal/server"
"omarchy-send/internal/transfer"
)
// SendFilesSync delivers a file and a folder over loopback HTTP and returns
// nil, with the contents arriving intact — the synchronous path used by
// headless `-to <alias> <paths…>` sends.
func TestSendFilesSyncSuccess(t *testing.T) {
recvDir := t.TempDir()
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53993, Protocol: "http",
}
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: true})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
go func() {
for range srv.Transfers() {
}
}()
time.Sleep(50 * time.Millisecond)
// One loose file plus a folder, so the relative-name path is covered too.
srcDir := t.TempDir()
loose := filepath.Join(srcDir, "report.pdf")
content := bytes.Repeat([]byte("headless-payload-"), 5000) // ~85KB
if err := os.WriteFile(loose, content, 0o644); err != nil {
t.Fatalf("write src: %v", err)
}
folder := filepath.Join(srcDir, "Trip")
if err := os.MkdirAll(filepath.Join(folder, "day1"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
nested := filepath.Join(folder, "day1", "img.jpg")
if err := os.WriteFile(nested, []byte("nested"), 0o644); err != nil {
t.Fatalf("write nested: %v", err)
}
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
var done []string
err := sender.SendFilesSync(ctx, peer, []string{loose, folder}, "", func(name string, size int64) {
done = append(done, name)
})
if err != nil {
t.Fatalf("SendFilesSync: %v", err)
}
if len(done) != 2 {
t.Fatalf("onDone called %d times, want 2 (%v)", len(done), done)
}
got, err := os.ReadFile(filepath.Join(recvDir, "report.pdf"))
if err != nil {
t.Fatalf("read received: %v", err)
}
if !bytes.Equal(got, content) {
t.Fatalf("content mismatch: %d vs %d bytes", len(got), len(content))
}
if _, err := os.Stat(filepath.Join(recvDir, "Trip", "day1", "img.jpg")); err != nil {
t.Fatalf("folder structure not recreated: %v", err)
}
}
// A missing path is a hard error before any network work — a script passing a
// wrong path wants a non-zero exit, not a silent skip.
func TestSendFilesSyncMissingPath(t *testing.T) {
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: protocol.DeviceInfo{Protocol: "http", Port: 1}, IP: "127.0.0.1"}
err := sender.SendFilesSync(context.Background(), peer, []string{"/no/such/file"}, "", nil)
if err == nil || !os.IsNotExist(errors.Unwrap(err)) && !os.IsNotExist(err) {
t.Fatalf("err = %v, want not-exist", err)
}
}
// A peer that requires a PIN rejects a PIN-less file send with ErrPinRequired,
// so the CLI can tell the user to pass -send-pin; with the PIN it goes through.
func TestSendFilesSyncPinRequired(t *testing.T) {
recvDir := t.TempDir()
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53994, Protocol: "http",
}
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: true, PIN: "2468"})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
go func() {
for range srv.Transfers() {
}
}()
time.Sleep(50 * time.Millisecond)
src := filepath.Join(t.TempDir(), "note.txt")
if err := os.WriteFile(src, []byte("pinned"), 0o644); err != nil {
t.Fatalf("write src: %v", err)
}
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
err := sender.SendFilesSync(ctx, peer, []string{src}, "", nil)
if !errors.Is(err, transfer.ErrPinRequired) {
t.Fatalf("err = %v, want ErrPinRequired", err)
}
if err := sender.SendFilesSync(ctx, peer, []string{src}, "2468", nil); err != nil {
t.Fatalf("SendFilesSync with PIN: %v", err)
}
if _, err := os.Stat(filepath.Join(recvDir, "note.txt")); err != nil {
t.Fatalf("file not received: %v", err)
}
}

View file

@ -0,0 +1,74 @@
package client
import (
"context"
"errors"
"testing"
"time"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
"omarchy-send/internal/server"
"omarchy-send/internal/transfer"
)
// SendMessageSync delivers the message and returns nil, with the text arriving
// on the receiver's Messages channel — the synchronous path used by headless
// `-to/-message` sends.
func TestSendMessageSyncSuccess(t *testing.T) {
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53991, Protocol: "http",
}
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: t.TempDir(), AutoAccept: false})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
go func() {
for range srv.Transfers() {
}
}()
time.Sleep(50 * time.Millisecond)
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
if err := sender.SendMessageSync(peer, "headless hello", ""); err != nil {
t.Fatalf("SendMessageSync: %v", err)
}
select {
case m := <-srv.Messages():
if m.Text != "headless hello" {
t.Fatalf("message text = %q", m.Text)
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for the message")
}
}
// A peer that requires a PIN rejects a PIN-less send with ErrPinRequired, so the
// CLI can tell the user to pass -send-pin.
func TestSendMessageSyncPinRequired(t *testing.T) {
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53992, Protocol: "http",
}
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: t.TempDir(), PIN: "2468"})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
time.Sleep(50 * time.Millisecond)
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
err := sender.SendMessageSync(peer, "no pin", "")
if !errors.Is(err, transfer.ErrPinRequired) {
t.Fatalf("err = %v, want ErrPinRequired", err)
}
// With the correct PIN it goes through.
if err := sender.SendMessageSync(peer, "with pin", "2468"); err != nil {
t.Fatalf("SendMessageSync with PIN: %v", err)
}
}

View file

@ -0,0 +1,54 @@
package client
import (
"context"
"os"
"testing"
"time"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
"omarchy-send/internal/server"
)
// A sent message must arrive on the receiver's Messages channel with its text
// and sender intact, and must NOT be written to disk as a file.
func TestSendMessageEndToEnd(t *testing.T) {
recvDir := t.TempDir()
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53990, Protocol: "http",
}
// AutoAccept is false on purpose: messages must bypass the accept prompt.
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: false})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
go func() {
for range srv.Transfers() {
}
}()
time.Sleep(50 * time.Millisecond)
sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
sender.SendMessage(peer, "hello over the wire\nsecond line", "")
select {
case m := <-srv.Messages():
if m.Text != "hello over the wire\nsecond line" {
t.Fatalf("message text = %q", m.Text)
}
if m.From != "sender" {
t.Errorf("message from = %q, want \"sender\"", m.From)
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for the message")
}
// A message is not a file: nothing should land in the receive dir.
if entries, _ := os.ReadDir(recvDir); len(entries) != 0 {
t.Fatalf("message should not be saved; found %d entries in receive dir", len(entries))
}
}

View file

@ -0,0 +1,43 @@
package client
import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
)
// The official LocalSend client answers a message prepare-upload with 204 No
// Content (the text rode in the preview field, so nothing needs uploading).
// SendMessageSync must treat that as success, not an error.
func TestSendMessageSync204IsSuccess(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, protocol.PathPrepareUpload) {
t.Errorf("unexpected request to %s", r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()
u, _ := url.Parse(ts.URL)
host, portStr, err := net.SplitHostPort(u.Host)
if err != nil {
t.Fatalf("split host: %v", err)
}
port, _ := strconv.Atoi(portStr)
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{
Info: protocol.DeviceInfo{Alias: "official", Protocol: "http", Port: port},
IP: host,
}
if err := sender.SendMessageSync(peer, "hi", ""); err != nil {
t.Fatalf("204 should be success, got: %v", err)
}
}

View file

@ -0,0 +1,88 @@
// Package clipboard reads and writes the system clipboard by shelling out to
// whichever helper is available: wl-clipboard (Wayland), xclip, xsel, or — when
// running inside tmux on a headless box — tmux's own paste buffer (which can
// reach the real clipboard via tmux's set-clipboard/OSC52). The binary stays
// dependency-free and static; these tools are optional, so a box with none
// simply reports the clipboard unavailable.
package clipboard
import (
"bytes"
"errors"
"os"
"os/exec"
"strings"
)
// ErrUnavailable means no supported clipboard helper is reachable.
var ErrUnavailable = errors.New("no clipboard available (need wl-clipboard, xclip, xsel, or tmux)")
// inTmux reports whether we're running inside a tmux session.
func inTmux() bool { return os.Getenv("TMUX") != "" }
// candidate is one clipboard helper invocation. eligible gates it beyond the
// binary simply existing on PATH (used to require an active tmux session).
type candidate struct {
bin string
args []string
eligible bool
}
// Read returns the current clipboard text, or ErrUnavailable if no helper is
// reachable. A single trailing newline is trimmed. System tools take priority;
// tmux's buffer is the fallback for headless sessions.
func Read() (string, error) {
for _, c := range []candidate{
{"wl-paste", []string{"--no-newline"}, true},
{"xclip", []string{"-selection", "clipboard", "-o"}, true},
{"xsel", []string{"--clipboard", "--output"}, true},
{"tmux", []string{"save-buffer", "-"}, inTmux()},
} {
if !c.eligible {
continue
}
if _, err := exec.LookPath(c.bin); err != nil {
continue
}
out, err := exec.Command(c.bin, c.args...).Output()
if err != nil {
return "", err
}
return strings.TrimRight(string(out), "\n"), nil
}
return "", ErrUnavailable
}
// Write sets the clipboard to text, or returns ErrUnavailable if no helper is
// reachable.
func Write(text string) error {
for _, c := range []candidate{
{"wl-copy", nil, true},
{"xclip", []string{"-selection", "clipboard"}, true},
{"xsel", []string{"--clipboard", "--input"}, true},
// -w also pushes the buffer to the outer terminal's clipboard when
// tmux's set-clipboard is on; we fall back to a plain load on failure.
{"tmux", []string{"load-buffer", "-w", "-"}, inTmux()},
} {
if !c.eligible {
continue
}
if _, err := exec.LookPath(c.bin); err != nil {
continue
}
if err := run(c.bin, c.args, text); err != nil {
if c.bin == "tmux" {
return run("tmux", []string{"load-buffer", "-"}, text) // older tmux: no -w
}
return err
}
return nil
}
return ErrUnavailable
}
func run(bin string, args []string, stdin string) error {
cmd := exec.Command(bin, args...)
cmd.Stdin = bytes.NewBufferString(stdin)
return cmd.Run()
}

View file

@ -0,0 +1,19 @@
package clipboard
import (
"errors"
"testing"
)
// With no helpers reachable on PATH, both operations report ErrUnavailable
// rather than panicking or hanging. (We empty PATH instead of touching the real
// clipboard, so the test is safe to run anywhere.)
func TestUnavailableWithoutTools(t *testing.T) {
t.Setenv("PATH", "")
if _, err := Read(); !errors.Is(err, ErrUnavailable) {
t.Errorf("Read with no tools: got %v, want ErrUnavailable", err)
}
if err := Write("x"); !errors.Is(err, ErrUnavailable) {
t.Errorf("Write with no tools: got %v, want ErrUnavailable", err)
}
}

View file

@ -6,11 +6,31 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"omarchy-send/internal/protocol" "omarchy-send/internal/protocol"
"omarchy-send/internal/security" "omarchy-send/internal/security"
) )
// ExpandHome resolves a leading "~" or "~/" to the user's home directory, so a
// receiveDir stored as "~/Omarchy-Send" (hand-edited, or typed into the
// Settings tab) means what the user means — and is not treated as a relative
// path that silently creates a literal "~" directory under the process cwd.
func ExpandHome(p string) string {
if p == "~" {
if home, err := os.UserHomeDir(); err == nil {
return home
}
return p
}
if strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, p[2:])
}
}
return p
}
// Config is the persisted user configuration. // Config is the persisted user configuration.
type Config struct { type Config struct {
Alias string `json:"alias"` Alias string `json:"alias"`
@ -21,8 +41,14 @@ type Config struct {
DeviceType string `json:"deviceType"` DeviceType string `json:"deviceType"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
AutoAccept bool `json:"autoAccept"` AutoAccept bool `json:"autoAccept"`
PIN string `json:"pin"` // if set, senders must supply it PIN string `json:"pin"` // if set, senders must supply it
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
// 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"`
@ -100,6 +126,9 @@ func Load() (Config, error) {
if cfg.ReceiveDir == "" { if cfg.ReceiveDir == "" {
cfg.ReceiveDir = d.ReceiveDir cfg.ReceiveDir = d.ReceiveDir
} }
// Normalise a ~-form receive dir to absolute; Load persists below, so the
// stored value is unambiguous from then on.
cfg.ReceiveDir = ExpandHome(cfg.ReceiveDir)
if cfg.DeviceType == "" { if cfg.DeviceType == "" {
cfg.DeviceType = d.DeviceType cfg.DeviceType = d.DeviceType
} }

View file

@ -0,0 +1,76 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// ExpandHome resolves the ~-forms a user may type or hand-edit, and leaves
// everything else untouched.
func TestExpandHome(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
cases := map[string]string{
"~": home,
"~/Omarchy-Send": filepath.Join(home, "Omarchy-Send"),
"~/a/b": filepath.Join(home, "a", "b"),
"/abs/path": "/abs/path",
"relative/path": "relative/path",
"~user/not-ours": "~user/not-ours", // ~user expansion is not supported
"mid/~/not-leading": "mid/~/not-leading",
"": "",
}
for in, want := range cases {
if got := ExpandHome(in); got != want {
t.Errorf("ExpandHome(%q) = %q, want %q", in, got, want)
}
}
}
// A config file whose receiveDir was stored as "~/…" is normalised to an
// absolute path by Load — the regression that sent files into a literal "~"
// directory under the process cwd.
func TestLoadExpandsTildeReceiveDir(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
cfgHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", cfgHome)
dir := filepath.Join(cfgHome, "omarchy-send")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
seed := map[string]any{"alias": "t", "receiveDir": "~/Omarchy-Send"}
data, _ := json.Marshal(seed)
if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
want := filepath.Join(home, "Omarchy-Send")
if cfg.ReceiveDir != want {
t.Fatalf("ReceiveDir = %q, want %q", cfg.ReceiveDir, want)
}
// And the normalised value is what got persisted back.
raw, err := os.ReadFile(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatalf("read back: %v", err)
}
var onDisk map[string]any
if err := json.Unmarshal(raw, &onDisk); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if onDisk["receiveDir"] != want {
t.Fatalf("persisted receiveDir = %q, want %q", onDisk["receiveDir"], want)
}
}

View file

@ -4,6 +4,7 @@ package dbg
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"sync" "sync"
"time" "time"
@ -27,6 +28,18 @@ func setup() {
f = file f = file
} }
// Writer returns the debug log file if $OMARCHY_SEND_LOG is set, else io.Discard.
// It's used to redirect the standard logger away from the terminal so stray
// stdlib logging (e.g. net/http's "unsolicited response" notice) can't corrupt
// the TUI; the output is still captured in the debug log when enabled.
func Writer() io.Writer {
once.Do(setup)
if f == nil {
return io.Discard
}
return f
}
// Logf appends a timestamped line to the debug log if $OMARCHY_SEND_LOG is set. // Logf appends a timestamped line to the debug log if $OMARCHY_SEND_LOG is set.
func Logf(format string, args ...any) { func Logf(format string, args ...any) {
once.Do(setup) once.Do(setup)

View file

@ -18,6 +18,7 @@ import (
"omarchy-send/internal/dbg" "omarchy-send/internal/dbg"
"omarchy-send/internal/protocol" "omarchy-send/internal/protocol"
"omarchy-send/internal/tsproxy"
) )
// EventKind distinguishes peer lifecycle events. // EventKind distinguishes peer lifecycle events.
@ -73,6 +74,11 @@ func New(self protocol.DeviceInfo) *Discoverer {
client: &http.Client{ client: &http.Client{
Timeout: 3 * time.Second, Timeout: 3 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
// Proxy env vars are honoured like the default transport, and
// tailnet destinations are auto-routed through the local
// tailscaled SOCKS5 proxy on userspace-networking boxes (no
// TUN), where direct outbound tailnet dials cannot work.
Proxy: tsproxy.ProxyFunc,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}, },
}, },
@ -83,6 +89,43 @@ func New(self protocol.DeviceInfo) *Discoverer {
// Events returns the channel on which peer events are delivered. // Events returns the channel on which peer events are delivered.
func (d *Discoverer) Events() <-chan Event { return d.events } func (d *Discoverer) Events() <-chan Event { return d.events }
// Snapshot returns a copy of the currently-known peers.
func (d *Discoverer) Snapshot() []Peer {
d.mu.Lock()
defer d.mu.Unlock()
out := make([]Peer, 0, len(d.peers))
for _, p := range d.peers {
out = append(out, p)
}
return out
}
// FindPeer waits for a known peer satisfying pred, checking peers already seen
// first and then ones discovered while waiting. It returns the first match, or
// ctx.Err() if the context is cancelled / times out first. It consumes the
// Events() channel, so it must not run concurrently with another Events()
// reader (e.g. the TUI bridge) — it is intended for the headless send path.
func (d *Discoverer) FindPeer(ctx context.Context, pred func(Peer) bool) (Peer, error) {
for _, p := range d.Snapshot() {
if pred(p) {
return p, nil
}
}
for {
select {
case <-ctx.Done():
return Peer{}, ctx.Err()
case ev, ok := <-d.events:
if !ok {
return Peer{}, ctx.Err()
}
if ev.Kind == PeerFound && pred(ev.Peer) {
return ev.Peer, nil
}
}
}
}
// SetAlias updates the advertised alias (and device model) at runtime. Call // SetAlias updates the advertised alias (and device model) at runtime. Call
// Announce afterwards to push it out immediately. // Announce afterwards to push it out immediately.
func (d *Discoverer) SetAlias(alias string) { func (d *Discoverer) SetAlias(alias string) {
@ -258,6 +301,67 @@ 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
}
// isLoopback reports whether ip parses as a loopback address (e.g. 127.0.0.1).
func isLoopback(ip string) bool {
p := net.ParseIP(ip)
return p != nil && p.IsLoopback()
}
// 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.
@ -269,7 +373,13 @@ func (d *Discoverer) NotePeer(info protocol.DeviceInfo, ip string) {
d.mu.Lock() d.mu.Lock()
prev, existed := d.peers[info.Fingerprint] prev, existed := d.peers[info.Fingerprint]
changed := !existed || prev.IP != ip || prev.Info.Alias != info.Alias // Never downgrade a routable address to loopback: behind a
// userspace-networking tailscaled, inbound registers all appear to come
// from 127.0.0.1 — recording that would make us "reply" to ourselves.
if existed && isLoopback(ip) && !isLoopback(prev.IP) {
peer.IP = prev.IP
}
changed := !existed || prev.IP != peer.IP || prev.Info.Alias != info.Alias
d.peers[info.Fingerprint] = peer d.peers[info.Fingerprint] = peer
d.mu.Unlock() d.mu.Unlock()

View file

@ -0,0 +1,56 @@
package discovery
import (
"context"
"testing"
"time"
)
// FindPeer returns immediately for a peer already in the snapshot.
func TestFindPeerAlreadyKnown(t *testing.T) {
d := New(mkInfo("self", "selffp", 0))
d.NotePeer(mkInfo("Strong Onion", "peerfp", 53317), "192.168.1.50")
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// Match is case-insensitive and whitespace-trimmed, like the CLI.
got, err := d.FindPeer(ctx, func(p Peer) bool {
return p.Info.Alias == "Strong Onion"
})
if err != nil {
t.Fatalf("FindPeer: %v", err)
}
if got.IP != "192.168.1.50" {
t.Errorf("IP = %q, want 192.168.1.50", got.IP)
}
}
// FindPeer resolves a peer that only appears after the call starts.
func TestFindPeerArrivesLater(t *testing.T) {
d := New(mkInfo("self", "selffp", 0))
go func() {
time.Sleep(50 * time.Millisecond)
d.NotePeer(mkInfo("Latecomer", "latefp", 53317), "10.0.0.9")
}()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got, err := d.FindPeer(ctx, func(p Peer) bool { return p.Info.Alias == "Latecomer" })
if err != nil {
t.Fatalf("FindPeer: %v", err)
}
if got.IP != "10.0.0.9" {
t.Errorf("IP = %q, want 10.0.0.9", got.IP)
}
}
// FindPeer returns the context error when no match arrives in time.
func TestFindPeerTimeout(t *testing.T) {
d := New(mkInfo("self", "selffp", 0))
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
if _, err := d.FindPeer(ctx, func(Peer) bool { return false }); err == nil {
t.Fatal("expected a timeout error, got nil")
}
}

View file

@ -0,0 +1,129 @@
package discovery
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"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)
}
}
// TestFindPeerViaProbe covers the headless-send path for remote peers: a peer
// that multicast can't see (here: only reachable by unicast Probe) must still
// satisfy a FindPeer that is already waiting — Probe → NotePeer → PeerFound.
func TestFindPeerViaProbe(t *testing.T) {
peerInfo := protocol.DeviceInfo{Alias: "titan-box", Fingerprint: "titan-fp", Port: 53317, Protocol: "http"}
mux := http.NewServeMux()
mux.HandleFunc("/api/localsend/v2/register", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(peerInfo)
})
srv := httptest.NewServer(mux)
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")
d := New(protocol.DeviceInfo{Fingerprint: "self-fp", Alias: "Self"})
// Probe concurrently, like watchRemotes does while FindPeer waits.
go func() {
if err := d.Probe(context.Background(), host); err != nil {
t.Errorf("Probe failed: %v", err)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
got, err := d.FindPeer(ctx, func(p Peer) bool {
return strings.EqualFold(strings.TrimSpace(p.Info.Alias), "titan-box")
})
if err != nil {
t.Fatalf("FindPeer did not see the probed peer: %v", err)
}
if got.Info.Fingerprint != "titan-fp" {
t.Errorf("fingerprint = %q, want titan-fp", got.Info.Fingerprint)
}
if wantIP := strings.Split(host, ":")[0]; got.IP != wantIP {
t.Errorf("peer IP = %q, want %q (the host probed)", got.IP, wantIP)
}
}
func TestProbeUnreachableErrors(t *testing.T) {
d := New(protocol.DeviceInfo{Fingerprint: "self-fp"})
// 127.0.0.1:1 — nothing listening; both https and http should fail fast.
if err := d.Probe(context.Background(), "127.0.0.1:1"); err == nil {
t.Fatal("expected an error probing an unreachable host")
}
}
// A register arriving via a userspace-networking tailscaled proxy appears to
// come from 127.0.0.1; that must not overwrite a peer's known routable address
// (sending to it would loop back to our own receiver).
func TestNotePeerKeepsRoutableOverLoopback(t *testing.T) {
d := New(protocol.DeviceInfo{Fingerprint: "self-fp"})
info := protocol.DeviceInfo{Alias: "gav", Fingerprint: "gav-fp", Port: 53317}
d.NotePeer(info, "100.91.41.111")
d.NotePeer(info, "127.0.0.1") // inbound register through the local proxy
if got := d.Snapshot()[0].IP; got != "100.91.41.111" {
t.Errorf("IP downgraded to %q, want 100.91.41.111 kept", got)
}
// A first sight at loopback is still recorded (nothing better known)…
d2 := New(protocol.DeviceInfo{Fingerprint: "self-fp"})
d2.NotePeer(info, "127.0.0.1")
if got := d2.Snapshot()[0].IP; got != "127.0.0.1" {
t.Errorf("first-sight IP = %q, want 127.0.0.1", got)
}
// …and upgrades to the routable address as soon as one is learned.
d2.NotePeer(info, "100.91.41.111")
if got := d2.Snapshot()[0].IP; got != "100.91.41.111" {
t.Errorf("IP = %q, want upgrade to 100.91.41.111", got)
}
}
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)
}
}

56
internal/notify/notify.go Normal file
View file

@ -0,0 +1,56 @@
// Package notify raises desktop notifications via libnotify's notify-send,
// which on Omarchy/Hyprland is displayed by the mako daemon. It is best-effort:
// when notify-send is absent or there is no graphical session bus (a genuinely
// headless box), it silently does nothing, so the receiver never blocks or
// errors on a machine with no desktop.
package notify
import (
"context"
"os"
"os/exec"
"time"
"omarchy-send/internal/dbg"
)
// appName is shown as the originating application in the notification daemon,
// and iconName is the bundled hicolor icon the installer drops in (falls back to
// no icon if the theme lacks it — harmless).
const (
appName = "Omarchy-Send"
iconName = "omarchy-send"
)
// Available reports whether a desktop notification can plausibly be shown:
// notify-send is on PATH and we appear to be inside a graphical session. The
// TUI calls this once at startup to decide whether to enable notifications.
func Available() bool {
if _, err := exec.LookPath("notify-send"); err != nil {
return false
}
return os.Getenv("WAYLAND_DISPLAY") != "" ||
os.Getenv("DISPLAY") != "" ||
os.Getenv("DBUS_SESSION_BUS_ADDRESS") != ""
}
// Send shows a desktop notification with the given summary and body. It returns
// immediately; the notify-send invocation runs in the background (bounded by a
// short timeout) and any failure is recorded only in the debug log. It is a
// no-op when Available reports false, so it is safe to call unconditionally.
func Send(summary, body string) {
if !Available() {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "notify-send",
"-a", appName,
"-i", iconName,
summary, body)
if err := cmd.Run(); err != nil {
dbg.Logf("notify-send failed: %v", err)
}
}()
}

View file

@ -0,0 +1,57 @@
package server
import (
"testing"
"omarchy-send/internal/protocol"
)
func TestMessageOf(t *testing.T) {
cases := []struct {
name string
files map[string]protocol.FileMetadata
wantOK bool
wantTxt string
}{
{
name: "text mime with preview is a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "text/plain", Preview: "hi"}},
wantOK: true,
wantTxt: "hi",
},
{
name: "bare text enum with preview is a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "text", Preview: "yo"}},
wantOK: true, wantTxt: "yo",
},
{
name: "text file without preview is a real file, not a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "text/plain", Preview: ""}},
wantOK: false,
},
{
name: "non-text with preview is not a message",
files: map[string]protocol.FileMetadata{"a": {FileType: "image/jpeg", Preview: "data"}},
wantOK: false,
},
{
name: "two files is never a message",
files: map[string]protocol.FileMetadata{
"a": {FileType: "text/plain", Preview: "hi"},
"b": {FileType: "text/plain", Preview: "bye"},
},
wantOK: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
txt, ok := messageOf(c.files)
if ok != c.wantOK {
t.Fatalf("ok = %v, want %v", ok, c.wantOK)
}
if ok && txt != c.wantTxt {
t.Fatalf("text = %q, want %q", txt, c.wantTxt)
}
})
}
}

View file

@ -51,6 +51,15 @@ type Server struct {
accepts chan AcceptRequest accepts chan AcceptRequest
transfers chan transfer.Event transfers chan transfer.Event
messages chan ReceivedMessage
}
// ReceivedMessage is a plain-text message received from a peer (LocalSend
// "send message": a single text file whose content rides in the preview field).
type ReceivedMessage struct {
From string
Text string
Time time.Time
} }
// New returns a Server from the given options. // New returns a Server from the given options.
@ -63,6 +72,7 @@ func New(opts Options) *Server {
sessions: newSessionStore(), sessions: newSessionStore(),
accepts: make(chan AcceptRequest, 8), accepts: make(chan AcceptRequest, 8),
transfers: make(chan transfer.Event, 256), transfers: make(chan transfer.Event, 256),
messages: make(chan ReceivedMessage, 32),
} }
s.autoAccept.Store(opts.AutoAccept) s.autoAccept.Store(opts.AutoAccept)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -122,6 +132,9 @@ func (s *Server) Accepts() <-chan AcceptRequest { return s.accepts }
// Transfers returns the channel of incoming-transfer progress events. // Transfers returns the channel of incoming-transfer progress events.
func (s *Server) Transfers() <-chan transfer.Event { return s.transfers } func (s *Server) Transfers() <-chan transfer.Event { return s.transfers }
// Messages returns the channel of received plain-text messages.
func (s *Server) Messages() <-chan ReceivedMessage { return s.messages }
// Start binds the listener and serves in the background until ctx is cancelled. // Start binds the listener and serves in the background until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error { func (s *Server) Start(ctx context.Context) error {
ln, err := net.Listen("tcp", s.http.Addr) ln, err := net.Listen("tcp", s.http.Addr)
@ -185,6 +198,15 @@ func (s *Server) handlePrepareUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
// A "message" is a single text file whose content rides in the preview
// field (LocalSend convention). It's received in full right here — surface
// it and return an empty file set so the sender uploads nothing.
if text, ok := messageOf(req.Files); ok {
s.emitMessage(ReceivedMessage{From: req.Info.Alias, Text: text, Time: time.Now()})
writeJSON(w, protocol.PrepareUploadResponse{SessionID: randToken(), Files: map[string]string{}})
return
}
if !s.askAccept(req, clientIP(r)) { if !s.askAccept(req, clientIP(r)) {
http.Error(w, "rejected", http.StatusForbidden) http.Error(w, "rejected", http.StatusForbidden)
return return
@ -194,6 +216,35 @@ func (s *Server) handlePrepareUpload(w http.ResponseWriter, r *http.Request) {
writeJSON(w, protocol.PrepareUploadResponse{SessionID: sess.id, Files: tokens}) writeJSON(w, protocol.PrepareUploadResponse{SessionID: sess.id, Files: tokens})
} }
// messageOf reports whether files represents a plain-text message (exactly one
// text file with non-empty preview) and returns the message text.
func messageOf(files map[string]protocol.FileMetadata) (string, bool) {
if len(files) != 1 {
return "", false
}
for _, f := range files {
if f.Preview != "" && isTextType(f.FileType) {
return f.Preview, true
}
}
return "", false
}
// isTextType matches both the MIME form ("text/plain") that LocalSend sends and
// the bare enum form ("text") older clients may use.
func isTextType(fileType string) bool {
return fileType == "text" || strings.HasPrefix(fileType, "text/")
}
// emitMessage delivers a received message without blocking the HTTP handler.
func (s *Server) emitMessage(m ReceivedMessage) {
dbg.Logf("received message from %q: %q", m.From, m.Text)
select {
case s.messages <- m:
default:
}
}
// askAccept honours auto-accept, or raises an AcceptRequest and blocks for the // askAccept honours auto-accept, or raises an AcceptRequest and blocks for the
// user's decision (with a timeout so a never-answered prompt can't wedge a // user's decision (with a timeout so a never-answered prompt can't wedge a
// peer's HTTP connection forever). // peer's HTTP connection forever).

View file

@ -0,0 +1,77 @@
// Package tailscale discovers peer addresses from a local Tailscale daemon, so
// omarchy-send can reach devices that share a tailnet but not a LAN subnet (and
// therefore can't be found by multicast). It shells out to the `tailscale` CLI
// and is a no-op when that isn't present.
package tailscale
import (
"context"
"encoding/json"
"os/exec"
)
// status is the subset of `tailscale status --json` we care about.
type status struct {
Peer map[string]struct {
TailscaleIPs []string `json:"TailscaleIPs"`
Online bool `json:"Online"`
} `json:"Peer"`
}
// Available reports whether the tailscale CLI is on PATH.
func Available() bool {
_, err := exec.LookPath("tailscale")
return err == nil
}
// Peers returns the IPv4 Tailscale address of each online peer in the tailnet.
// It returns nil (no error) when tailscale isn't installed, isn't running, or
// the output can't be parsed — callers treat Tailscale discovery as best-effort.
func Peers(ctx context.Context) []string {
if !Available() {
return nil
}
out, err := exec.CommandContext(ctx, "tailscale", "status", "--json").Output()
if err != nil {
return nil
}
return parsePeers(out)
}
// parsePeers extracts each online peer's first IPv4 address from the JSON of
// `tailscale status --json`. Split out so it can be tested without the CLI.
func parsePeers(data []byte) []string {
var st status
if err := json.Unmarshal(data, &st); err != nil {
return nil
}
var hosts []string
for _, p := range st.Peer {
if !p.Online {
continue
}
for _, ip := range p.TailscaleIPs {
if isIPv4(ip) {
hosts = append(hosts, ip)
break // one address per peer is enough to probe
}
}
}
return hosts
}
// isIPv4 reports whether s looks like a dotted-quad (cheap check — avoids
// pulling in net just to skip the IPv6 entries Tailscale also reports).
func isIPv4(s string) bool {
dots := 0
for _, c := range s {
switch {
case c == '.':
dots++
case c >= '0' && c <= '9':
default:
return false
}
}
return dots == 3
}

View file

@ -0,0 +1,46 @@
package tailscale
import (
"reflect"
"sort"
"testing"
)
func TestParsePeersOnlineIPv4Only(t *testing.T) {
data := []byte(`{
"Peer": {
"key1": {"TailscaleIPs": ["100.64.0.1", "fd7a:115c::1"], "Online": true},
"key2": {"TailscaleIPs": ["100.64.0.2"], "Online": false},
"key3": {"TailscaleIPs": ["fd7a:115c::3"], "Online": true},
"key4": {"TailscaleIPs": ["100.64.0.4"], "Online": true}
}
}`)
got := parsePeers(data)
sort.Strings(got)
want := []string{"100.64.0.1", "100.64.0.4"} // online + has IPv4; offline and v6-only excluded
if !reflect.DeepEqual(got, want) {
t.Errorf("parsePeers = %v, want %v", got, want)
}
}
func TestParsePeersBadJSON(t *testing.T) {
if got := parsePeers([]byte("not json")); got != nil {
t.Errorf("bad JSON should yield nil, got %v", got)
}
}
func TestIsIPv4(t *testing.T) {
cases := map[string]bool{
"100.64.0.1": true,
"1.2.3.4": true,
"fd7a:115c::1": false,
"1.2.3": false,
"": false,
"abc": false,
}
for in, want := range cases {
if got := isIPv4(in); got != want {
t.Errorf("isIPv4(%q) = %v, want %v", in, got, want)
}
}
}

111
internal/tsproxy/tsproxy.go Normal file
View file

@ -0,0 +1,111 @@
// Package tsproxy routes tailnet-bound HTTP connections through the local
// tailscaled SOCKS5 proxy when — and only when — the box needs it. On a box
// whose tailscaled runs with --tun=userspace-networking (e.g. an unprivileged
// container), there is no TUN interface, so ordinary outbound dials to
// 100.64.0.0/10 addresses cannot route; tailscaled's --socks5-server is the
// only outbound path. On a normal box the tailnet address is a local interface
// address and no proxy is involved.
package tsproxy
import (
"net"
"net/http"
"net/url"
"sync"
"time"
"omarchy-send/internal/dbg"
)
// conventionalAddr is where tailscaled's SOCKS5 proxy conventionally listens
// (--socks5-server=localhost:1055, the address used throughout Tailscale's
// userspace-networking docs). An explicit HTTPS_PROXY/HTTP_PROXY overrides it.
const conventionalAddr = "127.0.0.1:1055"
// detectTTL bounds how long a detection result is trusted, so a tailscaled
// (re)started after us is picked up without restarting omarchy-send.
const detectTTL = 30 * time.Second
// tailnetCIDR is Tailscale's CGNAT range; every tailnet IPv4 falls in it.
var tailnetCIDR = func() *net.IPNet {
_, n, _ := net.ParseCIDR("100.64.0.0/10")
return n
}()
// envProxy resolves the proxy environment variables; a seam for tests (the
// stdlib caches the env process-wide on first use).
var envProxy = http.ProxyFromEnvironment
// ProxyFunc is an http.Transport.Proxy implementation. Explicit proxy
// environment variables (HTTPS_PROXY/HTTP_PROXY/NO_PROXY) always win, like the
// default transport; otherwise requests to tailnet addresses are routed via
// the local tailscaled SOCKS5 proxy when the box can't dial them directly.
func ProxyFunc(req *http.Request) (*url.URL, error) {
if u, err := envProxy(req); err != nil || u != nil {
return u, err
}
if isTailnetHost(req.URL.Hostname()) {
return detect(), nil
}
return nil, nil
}
// isTailnetHost reports whether host is an IP in the Tailscale CGNAT range.
func isTailnetHost(host string) bool {
ip := net.ParseIP(host)
return ip != nil && tailnetCIDR.Contains(ip)
}
var (
mu sync.Mutex
checked time.Time
cached *url.URL // non-nil when tailnet dials must go through the proxy
)
// detect decides (with a short cache) whether tailnet destinations need the
// local SOCKS5 proxy: only when no local interface carries a tailnet address
// (i.e. userspace networking — a TUN box can dial directly) AND something is
// listening at the conventional proxy address.
func detect() *url.URL {
mu.Lock()
defer mu.Unlock()
if time.Since(checked) < detectTTL {
return cached
}
checked = time.Now()
prev := cached
cached = nil
if !hasLocalTailnetAddr() && listening(conventionalAddr) {
cached = &url.URL{Scheme: "socks5", Host: conventionalAddr}
}
if (cached == nil) != (prev == nil) {
dbg.Logf("tsproxy: tailnet SOCKS5 proxy at %s: %v", conventionalAddr, cached != nil)
}
return cached
}
// hasLocalTailnetAddr reports whether any local interface has a tailnet
// address — true on kernel-TUN tailscale boxes, false under userspace
// networking (the box's own tailnet IP is not a local address there).
func hasLocalTailnetAddr() bool {
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && tailnetCIDR.Contains(ipn.IP) {
return true
}
}
return false
}
// listening reports whether a TCP listener answers at addr.
func listening(addr string) bool {
c, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
if err != nil {
return false
}
_ = c.Close()
return true
}

View file

@ -0,0 +1,96 @@
package tsproxy
import (
"net/http"
"net/url"
"testing"
"time"
)
func TestIsTailnetHost(t *testing.T) {
cases := []struct {
host string
want bool
}{
{"100.90.62.102", true}, // tailnet (CGNAT range)
{"100.64.0.1", true}, // range start
{"100.127.255.254", true} /* range end */, {"100.128.0.1", false}, // just past the /10
{"192.168.1.46", false}, // LAN
{"127.0.0.1", false}, // loopback
{"colossus", false}, // hostname, not an IP
{"", false},
}
for _, c := range cases {
if got := isTailnetHost(c.host); got != c.want {
t.Errorf("isTailnetHost(%q) = %v, want %v", c.host, got, c.want)
}
}
}
// stubEnvProxy replaces the env-var proxy lookup for a test. The stdlib's
// ProxyFromEnvironment caches the environment process-wide on first use, so
// t.Setenv can't drive these tests reliably.
func stubEnvProxy(t *testing.T, u *url.URL) {
t.Helper()
orig := envProxy
envProxy = func(*http.Request) (*url.URL, error) { return u, nil }
t.Cleanup(func() { envProxy = orig })
}
// Explicit proxy env vars must win over auto-detection, like the default
// transport.
func TestProxyFuncEnvOverride(t *testing.T) {
stubEnvProxy(t, &url.URL{Scheme: "socks5", Host: "127.0.0.1:9999"})
prime(&url.URL{Scheme: "socks5", Host: conventionalAddr}) // detection would say 1055
req, _ := http.NewRequest(http.MethodGet, "https://100.90.62.102:53317/info", nil)
u, err := ProxyFunc(req)
if err != nil {
t.Fatalf("ProxyFunc: %v", err)
}
if u == nil || u.Host != "127.0.0.1:9999" {
t.Errorf("proxy = %v, want explicit socks5://127.0.0.1:9999", u)
}
}
// Non-tailnet destinations never get the auto-detected proxy.
func TestProxyFuncNonTailnetDirect(t *testing.T) {
stubEnvProxy(t, nil)
prime(&url.URL{Scheme: "socks5", Host: conventionalAddr})
req, _ := http.NewRequest(http.MethodGet, "https://192.168.1.46:53317/info", nil)
u, err := ProxyFunc(req)
if err != nil {
t.Fatalf("ProxyFunc: %v", err)
}
if u != nil {
t.Errorf("proxy = %v, want nil (direct) for a LAN destination", u)
}
}
// A tailnet destination uses the cached detection result; with the cache
// primed to "proxy present" the URL comes back, and direct otherwise.
func TestProxyFuncTailnetUsesDetection(t *testing.T) {
stubEnvProxy(t, nil)
req, _ := http.NewRequest(http.MethodGet, "https://100.90.62.102:53317/info", nil)
prime(&url.URL{Scheme: "socks5", Host: conventionalAddr})
u, err := ProxyFunc(req)
if err != nil {
t.Fatalf("ProxyFunc: %v", err)
}
if u == nil || u.Scheme != "socks5" || u.Host != conventionalAddr {
t.Errorf("proxy = %v, want socks5://%s", u, conventionalAddr)
}
prime(nil)
if u, _ := ProxyFunc(req); u != nil {
t.Errorf("proxy = %v, want nil when no proxy detected", u)
}
}
// prime seeds the detection cache for tests.
func prime(u *url.URL) {
mu.Lock()
defer mu.Unlock()
checked = time.Now()
cached = u
}

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

@ -1,7 +1,7 @@
// Package tui implements the Bubble Tea front end. It routes between Devices, // Package tui implements the Bubble Tea front end. It routes between Devices,
// Transfers, Manage (received-file housekeeping) and Settings screens, offers a // Transfers, Manage (received-file housekeeping), Messages and Settings screens,
// file picker for sending, and raises modal prompts for incoming files and for // offers a recursive fuzzy file finder for sending, and raises modal prompts for
// confirming deletions. // incoming files and for confirming deletions.
package tui package tui
import ( import (
@ -13,7 +13,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/charmbracelet/bubbles/filepicker"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
@ -21,6 +20,7 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"omarchy-send/internal/app" "omarchy-send/internal/app"
"omarchy-send/internal/clipboard"
"omarchy-send/internal/config" "omarchy-send/internal/config"
"omarchy-send/internal/discovery" "omarchy-send/internal/discovery"
"omarchy-send/internal/server" "omarchy-send/internal/server"
@ -32,10 +32,13 @@ import (
type Controller interface { type Controller interface {
Announce() Announce()
Send(peer discovery.Peer, paths []string, pin string) Send(peer discovery.Peer, paths []string, pin string)
SendMessage(peer discovery.Peer, text, pin string)
SetAutoAccept(bool) SetAutoAccept(bool)
SetAlias(string) SetAlias(string)
SetReceiveDir(string) SetReceiveDir(string)
SetPIN(string) SetPIN(string)
SetNotify(bool)
AddKnownPeer(host string) // probe a remote host directly (off-LAN / Tailscale)
} }
type screen int type screen int
@ -44,10 +47,11 @@ const (
screenPeers screen = iota screenPeers screen = iota
screenTransfers screenTransfers
screenManage screenManage
screenMessages
screenSettings screenSettings
screenPicker // entered from Peers; not part of the tab rotation screenPicker // entered from Peers; not part of the tab rotation
tabCount = 4 // Devices · Transfers · Manage · Settings tabCount = 5 // Devices · Transfers · Manage · Messages · Settings
) )
// xfer is one row in the transfers view. // xfer is one row in the transfers view.
@ -76,8 +80,19 @@ type Model struct {
peerList list.Model peerList list.Model
peers map[string]discovery.Peer peers map[string]discovery.Peer
picker filepicker.Model // Send screen: a recursive fuzzy file finder ("fzf") rooted at fzfRoot.
staged []string // file paths queued to send fzfQuery textinput.Model // the fuzzy query input
fzfRoot string // directory currently indexed
fzfEntries []fzfEntry // files+dirs found under fzfRoot
fzfRels []string // entry paths relative to fzfRoot (match corpus)
fzfMatches []int // indexes into fzfEntries, best-ranked first
fzfCursor int // selected row within fzfMatches
fzfIndexing bool // a walk is in flight
fzfTrunc bool // the walk hit the entry cap
fzfDirsOnly bool // show only folders (pick a whole dir to send)
fzfErr string // last walk error
staged []string // file/dir paths queued to send
target *discovery.Peer // peer we're sending to target *discovery.Peer // peer we're sending to
bar progress.Model bar progress.Model
@ -100,18 +115,61 @@ type Model struct {
editFocus int editFocus int
editInputs []textinput.Model // 0=alias, 1=receive dir, 2=pin editInputs []textinput.Model // 0=alias, 1=receive dir, 2=pin
// PIN prompt state for sending to a PIN-protected peer. // PIN prompt state for sending to a PIN-protected peer. pendingMsg is set
pinInput textinput.Model // (and sendPaths nil) when the pending send is a message rather than files,
showPin bool // so the PIN retry resends the right thing.
sendPeer *discovery.Peer pinInput textinput.Model
sendPaths []string showPin bool
sendPeer *discovery.Peer
sendPaths []string
pendingMsg string
// Add-remote-peer modal (Devices tab): host/IP/Tailscale-name entry.
addingPeer bool
peerInput textinput.Model
// Messages tab + compose modal.
msgList list.Model
messages []server.ReceivedMessage
composing bool
composeInput textinput.Model
composeTo *discovery.Peer
readingMsg *server.ReceivedMessage // non-nil while reading a message full-screen
notice string // transient footer flash (e.g. "message sent")
// quickSend is set by WithStagedFiles: the TUI opens with files already
// staged and selecting a device on the Devices screen sends them straight
// away (used by the Nautilus right-click integration).
quickSend bool
// quitAfterSend (also set by WithStagedFiles) auto-closes the window once
// the outgoing transfer completes, so the right-click box doesn't linger.
// quitPending guards against scheduling more than one quit tick at a time.
quitAfterSend bool
quitPending bool
width, height int width, height int
quitting bool quitting bool
} }
// Option customizes a Model at construction.
type Option func(*Model)
// WithStagedFiles opens the TUI in quick-send mode: paths are pre-staged and
// picking a device on the Devices screen sends them immediately, skipping the
// file finder. Empty input is a no-op (normal startup).
func WithStagedFiles(paths []string) Option {
return func(m *Model) {
if len(paths) == 0 {
return
}
m.staged = append([]string(nil), paths...)
m.quickSend = true
m.quitAfterSend = true
}
}
// New returns the root model. ctrl may be nil (e.g. in tests). // New returns the root model. ctrl may be nil (e.g. in tests).
func New(cfg config.Config, ctrl Controller) Model { func New(cfg config.Config, ctrl Controller, opts ...Option) Model {
applyTheme(theme.Load()) // match the active Omarchy theme applyTheme(theme.Load()) // match the active Omarchy theme
l := list.New(nil, deviceDelegate{icons: !cfg.NoIcons}, 0, 0) l := list.New(nil, deviceDelegate{icons: !cfg.NoIcons}, 0, 0)
@ -125,24 +183,32 @@ func New(cfg config.Config, ctrl Controller) Model {
fl.SetShowHelp(false) fl.SetShowHelp(false)
fl.SetShowStatusBar(false) fl.SetShowStatusBar(false)
fp := filepicker.New() ml := list.New(nil, msgDelegate{}, 0, 0)
if home, err := os.UserHomeDir(); err == nil { ml.SetShowTitle(false)
fp.CurrentDirectory = home ml.SetShowHelp(false)
} ml.SetShowStatusBar(false)
fp.AutoHeight = false
fp.Styles.Cursor = fp.Styles.Cursor.Foreground(accent) fzfQuery := textinput.New()
fp.Styles.Selected = fp.Styles.Selected.Foreground(accent).Bold(true) fzfQuery.Prompt = " "
fp.Styles.Directory = fp.Styles.Directory.Foreground(accent) fzfQuery.Placeholder = "type to fuzzy-find files…"
fp.Styles.File = fp.Styles.File.Foreground(text) fzfQuery.CharLimit = 128
fp.Styles.FileSize = fp.Styles.FileSize.Foreground(muted) fzfQuery.Width = 48
fp.Styles.Permission = fp.Styles.Permission.Foreground(muted) fzfQuery.PromptStyle = lipgloss.NewStyle().Foreground(accent)
fp.Styles.Symlink = fp.Styles.Symlink.Foreground(dim)
fp.Styles.EmptyDirectory = fp.Styles.EmptyDirectory.Foreground(muted)
pin := textinput.New() pin := textinput.New()
pin.Placeholder = "PIN" pin.Placeholder = "PIN"
pin.CharLimit = 16 pin.CharLimit = 16
compose := textinput.New()
compose.Placeholder = "type a message"
compose.CharLimit = 2000
compose.Width = 48
peerInput := textinput.New()
peerInput.Placeholder = "host, IP, or Tailscale name"
peerInput.CharLimit = 256
peerInput.Width = 48
mkInput := func(placeholder string, limit int) textinput.Model { mkInput := func(placeholder string, limit int) textinput.Model {
ti := textinput.New() ti := textinput.New()
ti.Placeholder = placeholder ti.Placeholder = placeholder
@ -156,22 +222,29 @@ func New(cfg config.Config, ctrl Controller) Model {
mkInput("PIN (blank = disabled)", 16), mkInput("PIN (blank = disabled)", 16),
} }
return Model{ m := Model{
cfg: cfg, cfg: cfg,
ctrl: ctrl, ctrl: ctrl,
ips: server.LocalIPs(), ips: server.LocalIPs(),
screen: screenPeers, screen: screenPeers,
peerList: l, peerList: l,
peers: make(map[string]discovery.Peer), peers: make(map[string]discovery.Peer),
fileList: fl, fileList: fl,
marked: marked, marked: marked,
picker: fp, fzfQuery: fzfQuery,
bar: progress.New(progress.WithDefaultGradient(), progress.WithWidth(22)), bar: progress.New(progress.WithDefaultGradient(), progress.WithWidth(22)),
xferIndex: make(map[string]*xfer), xferIndex: make(map[string]*xfer),
autoAccept: cfg.AutoAccept, autoAccept: cfg.AutoAccept,
pinInput: pin, pinInput: pin,
editInputs: editInputs, editInputs: editInputs,
msgList: ml,
composeInput: compose,
peerInput: peerInput,
} }
for _, o := range opts {
o(&m)
}
return m
} }
func (m Model) Init() tea.Cmd { return nil } func (m Model) Init() tea.Cmd { return nil }
@ -187,10 +260,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.peerList.SetSize(iw, lh) m.peerList.SetSize(iw, lh)
m.fileList.SetSize(iw, lh) m.fileList.SetSize(iw, lh)
if ph := ih - 8; ph >= 3 { m.msgList.SetSize(iw, lh)
m.picker.Height = ph if qw := iw - 4; qw > 8 {
} else { m.fzfQuery.Width = qw
m.picker.Height = 3
} }
return m, nil return m, nil
@ -218,9 +290,64 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, textinput.Blink return m, textinput.Blink
} }
m.applyTransfer(msg.Ev) m.applyTransfer(msg.Ev)
return m, m.maybeScheduleQuit()
case autoQuitMsg:
// The debounce elapsed — quit only if the send is still complete (a new
// file may have started since, e.g. mid-folder).
m.quitPending = false
if m.allSendsDoneOK() {
m.quitting = true
return m, tea.Quit
}
return m, nil
case app.MessageMsg:
m.messages = append([]server.ReceivedMessage{msg.Msg}, m.messages...)
m.msgList.SetItems(m.msgItems())
m.notice = "✉ message from " + nonEmpty(msg.Msg.From, "a device")
return m, nil
case fzfIndexedMsg:
if msg.root != m.fzfRoot {
return m, nil // a stale walk for a root we've since left
}
m.fzfIndexing = false
m.fzfEntries = msg.entries
m.fzfTrunc = msg.trunc
if msg.err != nil {
m.fzfErr = msg.err.Error()
} else {
m.fzfErr = ""
}
m.fzfRels = make([]string, len(msg.entries))
for i, e := range msg.entries {
m.fzfRels[i] = e.rel
}
m.recomputeMatches()
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
m.notice = "" // any key clears a transient footer notice
if m.composing {
return m.updateCompose(msg)
}
if m.addingPeer {
return m.updateAddPeer(msg)
}
if m.readingMsg != nil {
switch msg.String() {
case "esc", "q", "enter":
m.readingMsg = nil
case "y":
if err := clipboard.Write(m.readingMsg.Text); err != nil {
m.notice = "clipboard unavailable"
} else {
m.notice = "copied to clipboard"
}
}
return m, nil
}
if m.showPin { if m.showPin {
return m.updatePin(msg) return m.updatePin(msg)
} }
@ -234,7 +361,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateAccept(msg) return m.updateAccept(msg)
} }
if m.screen == screenPicker { if m.screen == screenPicker {
return m.updatePicker(msg) return m.updateFzf(msg)
} }
if m.screen == screenPeers && m.peerList.FilterState() == list.Filtering { if m.screen == screenPeers && m.peerList.FilterState() == list.Filtering {
break break
@ -257,6 +384,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshManage() m.refreshManage()
return m, nil return m, nil
case "4": case "4":
m.screen = screenMessages
return m, nil
case "5":
m.screen = screenSettings m.screen = screenSettings
return m, nil return m, nil
case "tab": case "tab":
@ -271,6 +401,15 @@ 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()
@ -293,6 +432,65 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.screen == screenManage { if m.screen == screenManage {
return m.requestDelete() return m.requestDelete()
} }
if m.screen == screenMessages {
m.deleteSelectedMessage()
}
return m, nil
case "m":
// Compose a message to the highlighted device.
if m.screen == screenPeers {
if it, ok := m.peerList.SelectedItem().(peerItem); ok {
peer := it.p
m.composeTo = &peer
m.composing = true
m.composeInput.SetValue("")
m.composeInput.Focus()
return m, textinput.Blink
}
}
return m, nil
case "v":
// Send the clipboard: open compose to the selected device,
// pre-filled with the clipboard text, so it can be reviewed first.
if m.screen == screenPeers {
if it, ok := m.peerList.SelectedItem().(peerItem); ok {
text, err := clipboard.Read()
if err != nil {
m.notice = "clipboard unavailable"
return m, nil
}
if strings.TrimSpace(text) == "" {
m.notice = "clipboard is empty"
return m, nil
}
peer := it.p
m.composeTo = &peer
m.composing = true
m.composeInput.SetValue(text)
m.composeInput.CursorEnd()
m.composeInput.Focus()
return m, textinput.Blink
}
}
return m, nil
case "y":
// Copy the selected/open message to the clipboard.
if m.screen == screenMessages {
var text string
if m.readingMsg != nil {
text = m.readingMsg.Text
} else if it, ok := m.msgList.SelectedItem().(msgItem); ok {
text = it.m.Text
}
if text == "" {
return m, nil
}
if err := clipboard.Write(text); err != nil {
m.notice = "clipboard unavailable"
} else {
m.notice = "copied to clipboard"
}
}
return m, nil return m, nil
case "a": case "a":
if m.screen == screenManage { if m.screen == screenManage {
@ -313,6 +511,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
_ = m.cfg.Save() _ = m.cfg.Save()
} }
return m, nil return m, nil
case "n":
if m.screen == screenSettings {
m.cfg.NoNotify = !m.cfg.NoNotify
if m.ctrl != nil {
m.ctrl.SetNotify(!m.cfg.NoNotify)
}
_ = m.cfg.Save()
}
return m, nil
case "e": case "e":
if m.screen == screenSettings { if m.screen == screenSettings {
return m.beginEdit() return m.beginEdit()
@ -322,18 +529,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.screen == screenPeers { if m.screen == screenPeers {
if it, ok := m.peerList.SelectedItem().(peerItem); ok { if it, ok := m.peerList.SelectedItem().(peerItem); ok {
peer := it.p peer := it.p
// Quick-send (Nautilus right-click): files are already
// staged — selecting a device sends them straight away. A
// PIN-required peer still routes through the PIN prompt via
// sendPeer/sendPaths.
if m.quickSend && len(m.staged) > 0 && m.ctrl != nil {
m.sendPeer = &peer
m.sendPaths = m.staged
m.pendingMsg = ""
m.ctrl.Send(peer, m.staged, "")
m.staged = nil
m.quickSend = false
m.screen = screenTransfers
return m, nil
}
m.target = &peer m.target = &peer
m.staged = nil m.staged = nil
home, err := os.UserHomeDir()
if err != nil || home == "" {
home = "."
}
m.fzfRoot = home
m.fzfQuery.SetValue("")
m.fzfQuery.Focus()
m.fzfEntries, m.fzfRels, m.fzfMatches, m.fzfCursor = nil, nil, nil, 0
m.fzfErr = ""
m.fzfDirsOnly = false
m.fzfIndexing = true
m.screen = screenPicker m.screen = screenPicker
return m, m.picker.Init() return m, tea.Batch(indexFiles(home), textinput.Blink)
} }
} }
if m.screen == screenMessages {
if it, ok := m.msgList.SelectedItem().(msgItem); ok {
msg := it.m
m.readingMsg = &msg
}
return m, nil
}
} }
} }
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.picker, cmd = m.picker.Update(msg) m.fzfQuery, cmd = m.fzfQuery.Update(msg)
return m, cmd return m, cmd
} }
if m.pending == nil && m.screen == screenPeers { if m.pending == nil && m.screen == screenPeers {
@ -346,49 +590,81 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.fileList, cmd = m.fileList.Update(msg) m.fileList, cmd = m.fileList.Update(msg)
return m, cmd return m, cmd
} }
if m.pending == nil && m.screen == screenMessages {
var cmd tea.Cmd
m.msgList, cmd = m.msgList.Update(msg)
return m, cmd
}
return m, nil return m, nil
} }
// updatePicker handles the file picker / staging screen. // updateCompose handles the message-compose modal.
func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc": case "esc":
m.screen = screenPeers m.composing = false
m.composeTo = nil
return m, nil return m, nil
case "ctrl+c", "q": case "enter":
m.quitting = true text := strings.TrimSpace(m.composeInput.Value())
return m, tea.Quit if text != "" && m.composeTo != nil && m.ctrl != nil {
case "a": peer := *m.composeTo
// Stage the folder currently being browsed; it is expanded into its // Remember the pending message so a PIN prompt can resend it.
// files (structure preserved) when the transfer starts. m.sendPeer = &peer
if dir := m.picker.CurrentDirectory; dir != "" && !contains(m.staged, dir) { m.sendPaths = nil
m.staged = append(m.staged, dir) m.pendingMsg = text
} m.ctrl.SendMessage(peer, text, "")
return m, nil m.notice = "✉ message sent to " + peer.Info.Alias
case "backspace":
if len(m.staged) > 0 {
m.staged = m.staged[:len(m.staged)-1]
}
return m, nil
case "S":
if len(m.staged) > 0 && m.target != nil && m.ctrl != nil {
m.sendPeer = m.target
m.sendPaths = m.staged
m.ctrl.Send(*m.target, m.staged, "")
m.staged = nil
m.screen = screenTransfers
} }
m.composing = false
m.composeTo = nil
return m, nil return m, nil
} }
var cmd tea.Cmd var cmd tea.Cmd
m.picker, cmd = m.picker.Update(msg) m.composeInput, cmd = m.composeInput.Update(msg)
if ok, path := m.picker.DidSelectFile(msg); ok { return m, cmd
if !contains(m.staged, path) { }
m.staged = append(m.staged, path)
// updateAddPeer handles the add-remote-device modal. On enter it persists the
// host to config's known-peers and asks the controller to probe it now.
func (m Model) updateAddPeer(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.addingPeer = false
return m, nil
case "enter":
host := strings.TrimSpace(m.peerInput.Value())
if host != "" {
if !contains(m.cfg.KnownPeers, host) {
m.cfg.KnownPeers = append(m.cfg.KnownPeers, host)
_ = m.cfg.Save()
}
if m.ctrl != nil {
m.ctrl.AddKnownPeer(host)
}
m.notice = "added remote " + host + " — probing…"
}
m.addingPeer = false
return m, nil
}
var cmd tea.Cmd
m.peerInput, cmd = m.peerInput.Update(msg)
return m, cmd
}
// deleteSelectedMessage drops the highlighted message from the list.
func (m *Model) deleteSelectedMessage() {
it, ok := m.msgList.SelectedItem().(msgItem)
if !ok {
return
}
for i := range m.messages {
if m.messages[i] == it.m {
m.messages = append(m.messages[:i], m.messages[i+1:]...)
break
} }
} }
return m, cmd m.msgList.SetItems(m.msgItems())
} }
// beginEdit enters the settings edit form, prefilling current values. // beginEdit enters the settings edit form, prefilling current values.
@ -458,7 +734,9 @@ func (m Model) saveEdit() (tea.Model, tea.Cmd) {
m.cfg.DeviceModel = alias m.cfg.DeviceModel = alias
} }
if dir != "" { if dir != "" {
m.cfg.ReceiveDir = dir // Expand a typed ~-form immediately so the live server and the saved
// config both carry the absolute path.
m.cfg.ReceiveDir = config.ExpandHome(dir)
} }
m.cfg.PIN = pin m.cfg.PIN = pin
_ = m.cfg.Save() _ = m.cfg.Save()
@ -479,8 +757,14 @@ func (m Model) updatePin(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showPin = false m.showPin = false
m.pinInput.Blur() m.pinInput.Blur()
if pin != "" && m.sendPeer != nil && m.ctrl != nil { if pin != "" && m.sendPeer != nil && m.ctrl != nil {
m.ctrl.Send(*m.sendPeer, m.sendPaths, pin) if m.pendingMsg != "" {
m.screen = screenTransfers m.ctrl.SendMessage(*m.sendPeer, m.pendingMsg, pin)
m.notice = "✉ message sent to " + m.sendPeer.Info.Alias
m.pendingMsg = ""
} else {
m.ctrl.Send(*m.sendPeer, m.sendPaths, pin)
m.screen = screenTransfers
}
} }
return m, nil return m, nil
case "esc", "ctrl+c": case "esc", "ctrl+c":
@ -506,6 +790,36 @@ func (m Model) updateAccept(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// autoQuitMsg fires after the post-send debounce to close a quick-send window.
type autoQuitMsg struct{}
// allSendsDoneOK reports whether every transfer has completed successfully —
// the trigger for auto-closing a quick-send window. A still-active, errored, or
// cancelled transfer (or none at all) returns false, so the window stays open
// for the user to see what happened.
func (m *Model) allSendsDoneOK() bool {
if len(m.transfers) == 0 {
return false
}
for _, x := range m.transfers {
if x.state != "done" {
return false
}
}
return true
}
// maybeScheduleQuit arms a debounced auto-quit when a quick-send has fully
// completed. The delay lets the user glimpse the ✓ and lets the next file in a
// multi-file/folder send start before we re-check at fire time.
func (m *Model) maybeScheduleQuit() tea.Cmd {
if !m.quitAfterSend || m.quitPending || !m.allSendsDoneOK() {
return nil
}
m.quitPending = true
return tea.Tick(1200*time.Millisecond, func(time.Time) tea.Msg { return autoQuitMsg{} })
}
func (m *Model) applyTransfer(ev transfer.Event) { func (m *Model) applyTransfer(ev transfer.Event) {
x, ok := m.xferIndex[ev.ID] x, ok := m.xferIndex[ev.ID]
if !ok { if !ok {
@ -588,6 +902,15 @@ func (m Model) View() string {
if m.confirmDel { if m.confirmDel {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.confirmDeleteView())) return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.confirmDeleteView()))
} }
if m.composing {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.composeView()))
}
if m.addingPeer {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.addPeerView()))
}
if m.readingMsg != nil {
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.readMessageView()))
}
cw, ih := innerDims(w, h) cw, ih := innerDims(w, h)
var body string var body string
@ -595,9 +918,17 @@ func (m Model) View() string {
switch m.screen { switch m.screen {
case screenPeers: case screenPeers:
if len(m.peers) == 0 { if len(m.peers) == 0 {
body, center = headerStyle.Render("Searching for devices on the network…"), true msg := "Searching for devices on the network…"
if m.quickSend {
msg += fmt.Sprintf("\n\n%d item(s) ready — pick a device to send.", len(m.staged))
}
body, center = headerStyle.Render(msg), true
} else { } else {
body = deviceHeader() + "\n" + m.peerList.View() head := deviceHeader()
if m.quickSend {
head = titleStyle.Render(fmt.Sprintf("Send %d item(s) — select a device:", len(m.staged))) + "\n" + head
}
body = head + "\n" + m.peerList.View()
} }
case screenTransfers: case screenTransfers:
if len(m.transfers) == 0 { if len(m.transfers) == 0 {
@ -614,6 +945,12 @@ func (m Model) View() string {
default: default:
body = m.manageView() body = m.manageView()
} }
case screenMessages:
if len(m.messages) == 0 {
body, center = headerStyle.Render("No messages yet.\nSelect a device and press m to send one."), true
} else {
body = messageHeader() + "\n" + m.msgList.View()
}
case screenSettings: case screenSettings:
if m.editing { if m.editing {
body, center = m.settingsEditView(), true body, center = m.settingsEditView(), true
@ -621,7 +958,7 @@ func (m Model) View() string {
body, center = m.settingsView(), true body, center = m.settingsView(), true
} }
case screenPicker: case screenPicker:
body = m.pickerView() body = m.sendView()
} }
if center { if center {
body = centerIn(cw, ih, body) body = centerIn(cw, ih, body)
@ -667,23 +1004,7 @@ func (m Model) tabBar() string {
} }
return tabInactiveStyle.Render(label) return tabInactiveStyle.Render(label)
} }
return " " + tab("Devices", screenPeers) + tab("Transfers", screenTransfers) + tab("Manage", screenManage) + tab("Settings", screenSettings) return " " + tab("Devices", screenPeers) + tab("Transfers", screenTransfers) + tab("Manage", screenManage) + tab("Messages", screenMessages) + tab("Settings", screenSettings)
}
func (m Model) pickerView() string {
target := ""
if m.target != nil {
target = m.target.Info.Alias
}
var b strings.Builder
b.WriteString(titleStyle.Render("Send to " + target))
b.WriteString(" ")
b.WriteString(headerStyle.Render(collapseHome(m.picker.CurrentDirectory)))
b.WriteString("\n\n")
b.WriteString(m.picker.View())
b.WriteString("\n")
b.WriteString(m.stagedPanel())
return b.String()
} }
// stagedPanel renders the queued files as a bordered box (or a hint when empty). // stagedPanel renders the queued files as a bordered box (or a hint when empty).
@ -693,7 +1014,7 @@ func (m Model) stagedPanel() string {
BorderForeground(muted). BorderForeground(muted).
Padding(0, 1) Padding(0, 1)
if len(m.staged) == 0 { if len(m.staged) == 0 {
return border.Render(headerStyle.Render("Nothing staged — enter adds a file, a adds the current folder.")) return border.Render(headerStyle.Render("Nothing staged — enter stages the highlighted file or folder."))
} }
var b strings.Builder var b strings.Builder
b.WriteString(titleStyle.Render(fmt.Sprintf("Staged · %d", len(m.staged)))) b.WriteString(titleStyle.Render(fmt.Sprintf("Staged · %d", len(m.staged))))
@ -723,6 +1044,18 @@ 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"))
@ -938,6 +1271,7 @@ func (m Model) settingsView() string {
{"Auto-accept", boolStr(m.autoAccept)}, {"Auto-accept", boolStr(m.autoAccept)},
{"PIN", boolStr(m.cfg.PIN != "")}, {"PIN", boolStr(m.cfg.PIN != "")},
{"Icons", boolStr(!m.cfg.NoIcons)}, {"Icons", boolStr(!m.cfg.NoIcons)},
{"Notifications", boolStr(!m.cfg.NoNotify)},
{"Local IPs", strings.Join(m.ips, ", ")}, {"Local IPs", strings.Join(m.ips, ", ")},
} }
for _, r := range rows { for _, r := range rows {
@ -967,20 +1301,32 @@ func (m Model) settingsEditView() string {
// footerText is the contextual help line shown at the bottom of the window. // footerText is the contextual help line shown at the bottom of the window.
func (m Model) footerText() string { func (m Model) footerText() string {
switch { switch {
case m.notice != "":
return m.notice
case m.composing:
return "enter send · esc cancel"
case m.addingPeer:
return "enter add remote · esc cancel"
case m.readingMsg != nil:
return "y copy · esc/enter close"
case m.confirmDel: case m.confirmDel:
return "y/enter delete · n/esc cancel" return "y/enter delete · n/esc cancel"
case m.editing: case m.editing:
return "tab/↑↓ move · enter next · ctrl+s save · esc cancel" return "tab/↑↓ move · enter next · ctrl+s save · esc cancel"
case m.screen == screenPicker: case m.screen == screenPicker:
return "enter stage file · a add folder · backspace unstage · S send · esc back" return "type filter · ↑↓ move · enter stage · ctrl+d folders-only · ctrl+s send · ctrl+u up-dir · esc back"
case m.screen == screenPeers && m.quickSend:
return fmt.Sprintf("enter send %d item(s) to selected device · r refresh · q cancel", len(m.staged))
case m.screen == screenPeers: case m.screen == screenPeers:
return "enter send-to · r refresh · / filter · 1-4 switch · q quit" return "enter send-to · m message · v clipboard · + add remote · r refresh · / filter · q quit"
case m.screen == screenTransfers: case m.screen == screenTransfers:
return "c clear finished · 1-4 switch · q quit" return "c clear finished · 1-5 switch · q quit"
case m.screen == screenManage: case m.screen == screenManage:
return "space mark · a all · d delete · r refresh · / filter · 1-4 switch · q quit" return "space mark · a all · d delete · r refresh · / filter · 1-5 switch · q quit"
case m.screen == screenMessages:
return "enter read · y copy · d delete · 1-5 switch · q quit"
case m.screen == screenSettings: case m.screen == screenSettings:
return "e edit · a auto-accept · i icons · 1-4 switch · q quit" return "e edit · a auto-accept · i icons · n notify · 1-5 switch · q quit"
} }
return "q quit" return "q quit"
} }
@ -1010,21 +1356,10 @@ func collapseHome(p string) string {
} }
// expandHome resolves a leading ~ (or ~/) to the user's home directory. It is // expandHome resolves a leading ~ (or ~/) to the user's home directory. It is
// the inverse of collapseHome and tolerates the ~-form a user may type into the // the inverse of collapseHome; the canonical implementation lives in config so
// receive-dir setting. // every consumer of ReceiveDir expands the same way.
func expandHome(p string) string { func expandHome(p string) string {
if p == "~" { return config.ExpandHome(p)
if home, err := os.UserHomeDir(); err == nil {
return home
}
return p
}
if strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, p[2:])
}
}
return p
} }
func truncate(s string, n int) string { func truncate(s string, n int) string {

262
internal/tui/send_test.go Normal file
View file

@ -0,0 +1,262 @@
package tui
import (
"os"
"path/filepath"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"omarchy-send/internal/config"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
"omarchy-send/internal/transfer"
)
// fakeCtrl records Send calls for quick-send tests.
type fakeCtrl struct {
sends int
sentPeer discovery.Peer
sentPaths []string
}
func (f *fakeCtrl) Announce() {}
func (f *fakeCtrl) Send(p discovery.Peer, paths []string, pin string) {
f.sends++
f.sentPeer = p
f.sentPaths = paths
}
func (f *fakeCtrl) SendMessage(p discovery.Peer, text, pin string) {}
func (f *fakeCtrl) SetAutoAccept(bool) {}
func (f *fakeCtrl) SetAlias(string) {}
func (f *fakeCtrl) SetReceiveDir(string) {}
func (f *fakeCtrl) SetPIN(string) {}
func (f *fakeCtrl) SetNotify(bool) {}
func (f *fakeCtrl) AddKnownPeer(string) {}
// writeTree lays out a small fixture tree under a temp dir for walkIndex tests.
func writeTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
mk := func(rel string) {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
mk("a.txt")
mk("sub/b.txt")
mk(".hidden/secret.txt") // dot-dir: skipped entirely
mk("node_modules/junk.txt") // denylisted dir: skipped entirely
return root
}
func TestWalkIndexSkipsNoiseAndDotDirs(t *testing.T) {
root := writeTree(t)
entries, trunc, err := walkIndex(root)
if err != nil {
t.Fatal(err)
}
if trunc {
t.Fatal("did not expect truncation")
}
got := map[string]bool{}
for _, e := range entries {
got[e.rel] = true
}
for _, want := range []string{"a.txt", "sub", filepath.Join("sub", "b.txt")} {
if !got[want] {
t.Errorf("expected %q in index, missing", want)
}
}
for _, unwanted := range []string{".hidden", filepath.Join(".hidden", "secret.txt"), "node_modules", filepath.Join("node_modules", "junk.txt")} {
if got[unwanted] {
t.Errorf("did not expect %q in index", unwanted)
}
}
}
func TestWalkIndexExcludesRootItself(t *testing.T) {
root := writeTree(t)
entries, _, err := walkIndex(root)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.path == root {
t.Fatal("root should not be indexed as an entry")
}
}
}
// seedFinder builds a Model with a hand-set index for match/stage tests.
func seedFinder(paths ...string) Model {
m := New(config.Config{}, nil)
for _, p := range paths {
m.fzfEntries = append(m.fzfEntries, fzfEntry{path: p, rel: p})
m.fzfRels = append(m.fzfRels, p)
}
return m
}
func TestRecomputeMatchesFuzzyRanks(t *testing.T) {
m := seedFinder("Documents/Q3-report.pdf", "Downloads/cat.jpg", "Projects/readme.md")
(&m).recomputeMatches() // empty query → everything
if len(m.fzfMatches) != 3 {
t.Fatalf("empty query: want 3 matches, got %d", len(m.fzfMatches))
}
m.fzfQuery.SetValue("report")
(&m).recomputeMatches()
if len(m.fzfMatches) == 0 {
t.Fatal("query 'report' matched nothing")
}
if best := m.fzfEntries[m.fzfMatches[0]].rel; best != "Documents/Q3-report.pdf" {
t.Errorf("best match = %q, want the report", best)
}
if m.fzfCursor != 0 {
t.Errorf("cursor should reset to best match, got %d", m.fzfCursor)
}
}
func TestSendViewRenders(t *testing.T) {
m := seedFinder("Documents/report.pdf", "Downloads/cat.jpg")
m.width, m.height = 90, 28
m.screen = screenPicker
m.fzfRoot = "/home/test"
(&m).recomputeMatches()
m.fzfCursor = 1
m.staged = []string{"/home/test/Downloads/cat.jpg"}
out := m.View()
if len(out) < 50 {
t.Fatalf("send view rendered too little:\n%s", out)
}
if !strings.Contains(out, "cat.jpg") {
t.Errorf("staged/listed file cat.jpg not shown in view:\n%s", out)
}
}
func TestWithStagedFilesEnablesQuickSend(t *testing.T) {
m := New(config.Config{}, nil, WithStagedFiles([]string{"/tmp/a.txt", "/tmp/b.txt"}))
if !m.quickSend {
t.Fatal("WithStagedFiles should enable quickSend")
}
if len(m.staged) != 2 {
t.Fatalf("want 2 pre-staged paths, got %d", len(m.staged))
}
// Empty input must not flip into quick-send mode.
plain := New(config.Config{}, nil, WithStagedFiles(nil))
if plain.quickSend {
t.Error("empty WithStagedFiles should be a no-op")
}
}
func TestQuickSendSelectingDeviceSends(t *testing.T) {
fc := &fakeCtrl{}
m := New(config.Config{}, fc, WithStagedFiles([]string{"/tmp/a.txt", "/tmp/b.txt"}))
peer := discovery.Peer{Info: protocol.DeviceInfo{Alias: "Target", Fingerprint: "fp1"}}
m.peers["fp1"] = peer
m.peerList.SetItems(m.peerItems())
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
got := nm.(Model)
if fc.sends != 1 {
t.Fatalf("want exactly 1 send, got %d", fc.sends)
}
if len(fc.sentPaths) != 2 {
t.Errorf("want 2 paths sent, got %d", len(fc.sentPaths))
}
if fc.sentPeer.Info.Alias != "Target" {
t.Errorf("sent to %q, want Target", fc.sentPeer.Info.Alias)
}
if got.screen != screenTransfers {
t.Errorf("after quick-send want screenTransfers, got %v", got.screen)
}
if got.quickSend {
t.Error("quickSend should reset after sending")
}
if got.sendPeer == nil || got.sendPaths == nil {
t.Error("sendPeer/sendPaths should be retained for a possible PIN retry")
}
}
func TestQuickSendAutoQuitOnlyWhenAllDone(t *testing.T) {
m := New(config.Config{}, nil, WithStagedFiles([]string{"/tmp/a.txt"}))
if !m.quitAfterSend {
t.Fatal("WithStagedFiles should arm quitAfterSend")
}
// Active transfer: do not schedule a quit.
m.applyTransfer(transfer.Event{ID: "1", Dir: transfer.Outgoing, Kind: transfer.Start, Total: 10, Received: 0})
if cmd := m.maybeScheduleQuit(); cmd != nil {
t.Fatal("should not schedule quit while a transfer is active")
}
// A second file starts; first finishes — still one active, no quit.
m.applyTransfer(transfer.Event{ID: "2", Dir: transfer.Outgoing, Kind: transfer.Start, Total: 10})
m.applyTransfer(transfer.Event{ID: "1", Kind: transfer.FileDone})
if m.allSendsDoneOK() {
t.Fatal("not all sends are done yet (file 2 active)")
}
// Both done → schedules a quit.
m.applyTransfer(transfer.Event{ID: "2", Kind: transfer.FileDone})
if cmd := m.maybeScheduleQuit(); cmd == nil {
t.Fatal("should schedule quit once all sends are done")
}
// An errored transfer must NOT auto-quit (leave the box open).
em := New(config.Config{}, nil, WithStagedFiles([]string{"/tmp/x"}))
em.applyTransfer(transfer.Event{ID: "9", Dir: transfer.Outgoing, Kind: transfer.Error})
if em.allSendsDoneOK() {
t.Error("errored transfer should not count as done")
}
}
func TestDirsOnlyFiltersToFolders(t *testing.T) {
m := New(config.Config{}, nil)
m.fzfEntries = []fzfEntry{
{path: "/r/Photos", rel: "Photos", dir: true},
{path: "/r/Photos/a.jpg", rel: "Photos/a.jpg"},
{path: "/r/notes.txt", rel: "notes.txt"},
}
m.fzfRels = []string{"Photos", "Photos/a.jpg", "notes.txt"}
(&m).recomputeMatches()
if len(m.fzfMatches) != 3 {
t.Fatalf("default: want all 3, got %d", len(m.fzfMatches))
}
m.fzfDirsOnly = true
(&m).recomputeMatches()
if len(m.fzfMatches) != 1 {
t.Fatalf("dirs-only: want 1 folder, got %d", len(m.fzfMatches))
}
if got := m.fzfEntries[m.fzfMatches[0]].rel; got != "Photos" {
t.Errorf("dirs-only kept %q, want Photos", got)
}
}
func TestToggleStageCursorAddsAndRemoves(t *testing.T) {
m := seedFinder("a.txt", "b.txt")
(&m).recomputeMatches()
m.fzfCursor = 1
(&m).toggleStageCursor()
if !contains(m.staged, "b.txt") {
t.Fatal("toggle should stage b.txt")
}
(&m).toggleStageCursor()
if contains(m.staged, "b.txt") {
t.Fatal("second toggle should unstage b.txt")
}
}

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

View file

@ -0,0 +1,114 @@
package tui
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"omarchy-send/internal/server"
)
// msgItem adapts a received message to bubbles/list.Item.
type msgItem struct{ m server.ReceivedMessage }
func (i msgItem) Title() string { return i.m.From }
func (i msgItem) Description() string { return i.m.Text }
func (i msgItem) FilterValue() string { return i.m.From + " " + i.m.Text }
// msgItems builds list items from the model's messages (kept newest-first).
func (m Model) msgItems() []list.Item {
items := make([]list.Item, len(m.messages))
for i, msg := range m.messages {
items[i] = msgItem{m: msg}
}
return items
}
// Message list column widths.
const (
colFrom = 22
colTime = 7
)
// messageHeader is the dim column-header row above the message list.
func messageHeader() string {
h := lipgloss.NewStyle().Foreground(muted)
return " " +
h.Width(colFrom).Render("From") +
h.Width(colTime).Render("Time") +
h.Render("Message")
}
// msgDelegate renders one message as a row: ▌ from 15:04 preview…
type msgDelegate struct{}
func (msgDelegate) Height() int { return 1 }
func (msgDelegate) Spacing() int { return 0 }
func (msgDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
func (msgDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
it, ok := item.(msgItem)
if !ok {
return
}
from := truncate(nonEmpty(it.m.From, "unknown"), colFrom-2)
ts := it.m.Time.Format("15:04")
preview := truncate(oneLine(it.m.Text), 60)
fromSt := lipgloss.NewStyle().Width(colFrom)
timeSt := lipgloss.NewStyle().Width(colTime)
if index == m.Index() {
fmt.Fprint(w, lipgloss.NewStyle().Foreground(accent).Render("▌ ")+
fromSt.Foreground(accent).Bold(true).Render(from)+
timeSt.Foreground(accent).Render(ts)+
lipgloss.NewStyle().Foreground(accent).Render(preview))
return
}
fmt.Fprint(w, " "+
fromSt.Foreground(text).Render(from)+
timeSt.Foreground(dim).Render(ts)+
lipgloss.NewStyle().Foreground(muted).Render(preview))
}
// composeView is the message-compose card.
func (m Model) composeView() string {
to := ""
if m.composeTo != nil {
to = m.composeTo.Info.Alias
}
var b strings.Builder
b.WriteString(titleStyle.Render("Message to " + to))
b.WriteString("\n\n")
b.WriteString(m.composeInput.View())
return b.String()
}
// readMessageView shows the full text of the message being read.
func (m Model) readMessageView() string {
if m.readingMsg == nil {
return ""
}
var b strings.Builder
b.WriteString(titleStyle.Render("Message from " + nonEmpty(m.readingMsg.From, "unknown")))
b.WriteString(" ")
b.WriteString(headerStyle.Render(m.readingMsg.Time.Format("2006-01-02 15:04")))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Width(56).Foreground(text).Render(m.readingMsg.Text))
return b.String()
}
func nonEmpty(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
// oneLine collapses newlines so a multi-line message previews on one row.
func oneLine(s string) string {
return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\r", " "))
}

320
internal/tui/view_send.go Normal file
View file

@ -0,0 +1,320 @@
package tui
import (
"fmt"
"io/fs"
"path/filepath"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sahilm/fuzzy"
)
// fzfMaxEntries caps how many paths the recursive walk indexes, so the send
// finder stays responsive even when rooted at a huge tree. When the cap is hit
// the header shows a trailing "+".
const fzfMaxEntries = 50000
// fzfSkipDirs are directory names the walk never descends into: build/cache
// noise that would crowd out real files (and balloon the index) when sending.
// Dot-directories are skipped too (see walkIndex), except the root itself.
var fzfSkipDirs = map[string]bool{
"node_modules": true,
".git": true,
".cache": true,
".svn": true,
"__pycache__": true,
".venv": true,
"vendor": true,
}
// fzfEntry is one indexed path under the finder's root.
type fzfEntry struct {
path string // absolute path
rel string // path relative to the root — the fuzzy-match corpus + display
dir bool
}
// fzfIndexedMsg carries the result of a background walk back to the model. The
// root is echoed so a stale result (from a root the user has since changed) can
// be ignored.
type fzfIndexedMsg struct {
root string
entries []fzfEntry
trunc bool
err error
}
// indexFiles walks root in the background and returns its files+folders.
func indexFiles(root string) tea.Cmd {
return func() tea.Msg {
entries, trunc, err := walkIndex(root)
return fzfIndexedMsg{root: root, entries: entries, trunc: trunc, err: err}
}
}
// walkIndex recursively lists files and directories under root, skipping
// dot-directories and the fzfSkipDirs noise, capped at fzfMaxEntries. Unreadable
// directories are skipped rather than aborting the walk.
func walkIndex(root string) ([]fzfEntry, bool, error) {
root = filepath.Clean(root)
var entries []fzfEntry
truncated := false
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
if d != nil && d.IsDir() {
return fs.SkipDir // unreadable dir — skip it, keep going
}
return nil
}
if len(entries) >= fzfMaxEntries {
truncated = true
return filepath.SkipAll
}
if path == root {
return nil // don't index the root itself
}
name := d.Name()
if d.IsDir() {
if strings.HasPrefix(name, ".") || fzfSkipDirs[name] {
return fs.SkipDir
}
}
rel, rerr := filepath.Rel(root, path)
if rerr != nil {
rel = path
}
entries = append(entries, fzfEntry{path: path, rel: rel, dir: d.IsDir()})
return nil
})
if err != nil {
return entries, truncated, err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].rel < entries[j].rel })
return entries, truncated, nil
}
// recomputeMatches refreshes the ranked match list for the current query and
// snaps the cursor back to the best match. When fzfDirsOnly is set, only
// directories are kept — for quickly picking a whole folder to send.
func (m *Model) recomputeMatches() {
m.fzfMatches = m.fzfMatches[:0]
keep := func(i int) bool { return !m.fzfDirsOnly || m.fzfEntries[i].dir }
if q := strings.TrimSpace(m.fzfQuery.Value()); q == "" {
for i := range m.fzfEntries {
if keep(i) {
m.fzfMatches = append(m.fzfMatches, i)
}
}
} else {
for _, r := range fuzzy.Find(q, m.fzfRels) {
if keep(r.Index) {
m.fzfMatches = append(m.fzfMatches, r.Index)
}
}
}
m.fzfCursor = 0
}
// toggleStageCursor stages (or unstages) the highlighted entry.
func (m *Model) toggleStageCursor() {
if m.fzfCursor < 0 || m.fzfCursor >= len(m.fzfMatches) {
return
}
p := m.fzfEntries[m.fzfMatches[m.fzfCursor]].path
if contains(m.staged, p) {
out := m.staged[:0]
for _, q := range m.staged {
if q != p {
out = append(out, q)
}
}
m.staged = out
return
}
m.staged = append(m.staged, p)
}
// updateFzf handles keys on the send (fuzzy-finder) screen. Printable keys edit
// the query; navigation and actions use arrows and ctrl-combos so they don't
// collide with typing.
func (m Model) updateFzf(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.screen = screenPeers
return m, nil
case "ctrl+c":
m.quitting = true
return m, tea.Quit
case "up", "ctrl+p":
if m.fzfCursor > 0 {
m.fzfCursor--
}
return m, nil
case "down", "ctrl+n":
if m.fzfCursor < len(m.fzfMatches)-1 {
m.fzfCursor++
}
return m, nil
case "enter":
m.toggleStageCursor()
return m, nil
case "ctrl+d":
m.fzfDirsOnly = !m.fzfDirsOnly
m.recomputeMatches()
return m, nil
case "ctrl+s":
if len(m.staged) > 0 && m.target != nil && m.ctrl != nil {
m.sendPeer = m.target
m.sendPaths = m.staged
m.pendingMsg = "" // a file send, not a message
m.ctrl.Send(*m.target, m.staged, "")
m.staged = nil
m.screen = screenTransfers
}
return m, nil
case "ctrl+u":
if parent := filepath.Dir(m.fzfRoot); parent != "" && parent != m.fzfRoot {
m.fzfRoot = parent
m.fzfIndexing = true
m.fzfErr = ""
m.fzfEntries, m.fzfRels, m.fzfMatches, m.fzfCursor = nil, nil, nil, 0
return m, indexFiles(parent)
}
return m, nil
}
var cmd tea.Cmd
m.fzfQuery, cmd = m.fzfQuery.Update(msg)
m.recomputeMatches()
return m, cmd
}
// sendView renders the fuzzy-finder send screen: header, query, ranked matches,
// and the staged panel — sized to fill the framed area.
func (m Model) sendView() string {
target := ""
if m.target != nil {
target = m.target.Info.Alias
}
status := collapseHome(m.fzfRoot)
if m.fzfIndexing {
status += " · indexing…"
} else {
status += fmt.Sprintf(" · %d items", len(m.fzfEntries))
if m.fzfTrunc {
status += "+"
}
}
if m.fzfDirsOnly {
status += " · folders only"
}
header := titleStyle.Render("Send to "+target) + " " + headerStyle.Render(status)
var errLine string
if m.fzfErr != "" {
errLine = lipgloss.NewStyle().Foreground(bad).Render(" "+m.fzfErr) + "\n"
}
staged := m.stagedPanel()
_, ih := innerDims(m.width, m.height)
used := 2 + lipgloss.Height(staged) // header + query rows + staged panel
if errLine != "" {
used++
}
rows := ih - used
if rows < 1 {
rows = 1
}
var b strings.Builder
b.WriteString(header + "\n")
b.WriteString(m.fzfQuery.View() + "\n")
b.WriteString(errLine)
b.WriteString(m.fzfListView(rows) + "\n")
b.WriteString(staged)
return b.String()
}
// fzfListView renders exactly rows lines of the match window around the cursor.
func (m Model) fzfListView(rows int) string {
if rows < 1 {
rows = 1
}
switch {
case m.fzfIndexing && len(m.fzfEntries) == 0:
return padLines(headerStyle.Render(" indexing "+collapseHome(m.fzfRoot)+" …"), rows)
case len(m.fzfMatches) == 0:
hint := " no files here"
switch {
case strings.TrimSpace(m.fzfQuery.Value()) != "":
hint = " no matches"
case m.fzfDirsOnly:
hint = " no folders here"
}
return padLines(headerStyle.Render(hint), rows)
}
// Scroll so the cursor stays within the visible window.
start := 0
if m.fzfCursor >= rows {
start = m.fzfCursor - rows + 1
}
end := start + rows
if end > len(m.fzfMatches) {
end = len(m.fzfMatches)
if start = end - rows; start < 0 {
start = 0
}
}
dirStyle := lipgloss.NewStyle().Foreground(accent)
selStyle := lipgloss.NewStyle().Foreground(accent).Bold(true)
dotStyle := lipgloss.NewStyle().Foreground(good)
var b strings.Builder
for i := start; i < end; i++ {
e := m.fzfEntries[m.fzfMatches[i]]
label := collapseHome(e.path)
if e.dir {
label += "/"
}
cursor := " "
if i == m.fzfCursor {
cursor = selStyle.Render("▌ ")
}
dot := " "
if contains(m.staged, e.path) {
dot = dotStyle.Render("●")
}
switch {
case i == m.fzfCursor:
label = selStyle.Render(label)
case e.dir:
label = dirStyle.Render(label)
default:
label = valueStyle.Render(label)
}
if i > start {
b.WriteByte('\n')
}
b.WriteString(cursor + dot + " " + label)
}
return padLines(b.String(), rows)
}
// padLines forces s to exactly n lines: truncating extra lines and padding short
// blocks with blanks, so the surrounding layout stays stable.
func padLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[:n]
}
for len(lines) < n {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}

109
nautilus/omarchy-send.py Normal file
View file

@ -0,0 +1,109 @@
"""Nautilus right-click integration for Omarchy-Send.
Adds a "Send via Omarchy-Send" entry to the file/folder context menu. Because
omarchy-send is a terminal app, the entry opens it in a floating presentation
terminal (the same wrapper Omarchy's Transcode entry uses), passing the selected
paths so they arrive pre-staged on the device list pick a device and send.
Both files and directories are supported (omarchy-send expands a folder on send).
Note on resolution: omarchy-send installs to ~/.local/bin, which is often NOT on
the PATH the Nautilus process (and the terminal it spawns) inherits from the
graphical session. So we resolve it to an absolute path PATH first, then the
default install locations under $HOME and invoke it by that absolute path.
Installed to ~/.local/share/nautilus-python/extensions/ by omarchy-send's
install.sh on Omarchy desktops.
"""
import os
import shlex
import shutil
from gi import require_version
require_version("Nautilus", "4.1")
from gi.repository import GObject, Gio, Nautilus
def _resolve(name, fallbacks):
"""Find an executable by PATH, then by a list of absolute fallback paths."""
found = shutil.which(name)
if found:
return found
for path in fallbacks:
if path and os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def _binary():
home = os.path.expanduser("~")
fallbacks = []
bin_dir = os.environ.get("BIN_DIR")
if bin_dir:
fallbacks.append(os.path.join(bin_dir, "omarchy-send"))
fallbacks.append(os.path.join(home, ".local", "bin", "omarchy-send"))
fallbacks.append(os.path.join(home, "bin", "omarchy-send"))
return _resolve("omarchy-send", fallbacks)
def _wrapper():
home = os.path.expanduser("~")
fallbacks = [
os.path.join(home, ".local", "share", "omarchy", "bin",
"omarchy-launch-floating-terminal-with-presentation"),
]
return _resolve("omarchy-launch-floating-terminal-with-presentation", fallbacks)
class OmarchySendAction(GObject.GObject, Nautilus.MenuProvider):
def _launch(self, paths):
wrapper = _wrapper()
binary = _binary()
if not wrapper or not binary:
return
# Use the absolute binary path: the wrapper's `bash -c` may not have
# ~/.local/bin on its PATH either.
cmd = shlex.join([binary, *paths])
Gio.Subprocess.new([wrapper, cmd], Gio.SubprocessFlags.NONE)
def _selected_paths(self, files):
paths = []
seen = set()
for file in files:
location = file.get_location()
if not location:
continue
path = location.get_path()
if path and path not in seen:
seen.add(path)
paths.append(path)
return paths
def _make_item(self, paths):
label = (
"Send via Omarchy-Send"
if len(paths) == 1
else f"Send {len(paths)} items via Omarchy-Send"
)
item = Nautilus.MenuItem(
name="OmarchySendNautilus::send",
label=label,
icon="omarchy-send",
)
item.connect("activate", self._on_activate, paths)
return item
def _on_activate(self, _menu, paths):
self._launch(paths)
def get_file_items(self, *args):
files = args[0] if len(args) == 1 else args[1]
if not _wrapper() or not _binary():
return []
paths = self._selected_paths(files)
if not paths:
return []
return [self._make_item(paths)]