Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39280e5590 | |||
| d68421ac97 | |||
| 2342beff10 | |||
| a93cdcaa83 | |||
| 38f057db67 | |||
| ee9060e6d0 | |||
| 05e70f127d | |||
| 7d6e339dcb | |||
| ff67bd6a71 | |||
| a15440ed20 | |||
| e57c426327 | |||
| 2c058031fd | |||
| 5dfaab4018 | |||
| cfa719e2b4 | |||
| e66662feb9 | |||
| 098c34c546 | |||
| 7ab3f905ee | |||
| 6e7aac090c | |||
| beb6b9040e |
38 changed files with 4241 additions and 188 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +1,6 @@
|
||||||
/omarchy-send
|
/omarchy-send
|
||||||
/dist/omarchy-send-linux-*
|
/dist/omarchy-send-linux-*
|
||||||
*.tmp
|
*.tmp
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
dist/
|
||||||
|
|
|
||||||
206
README.md
206
README.md
|
|
@ -10,17 +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 with a built-in file picker, and upload
|
- **Send** — pick a peer, then find what to send with a built-in **recursive
|
||||||
them with progress, rate and ETA.
|
fuzzy finder**: type part of a name to match files (and folders) anywhere under
|
||||||
|
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
|
||||||
|
|
||||||
|
|
@ -31,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.
|
||||||
|
|
@ -59,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`
|
||||||
|
|
@ -90,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 · `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
|
||||||
|
|
||||||
|
|
@ -116,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).
|
||||||
|
|
|
||||||
|
|
@ -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
3
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
466
install.sh
466
install.sh
|
|
@ -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:"*) : ;;
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
70
internal/app/events_test.go
Normal file
70
internal/app/events_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
internal/client/cancel_test.go
Normal file
94
internal/client/cancel_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"mime"
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -20,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
|
||||||
|
|
@ -41,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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,24 +99,148 @@ func (s *Sender) Send(peer discovery.Peer, paths []string, pin string) {
|
||||||
go s.send(peer, paths, pin)
|
go s.send(peer, paths, pin)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
|
// SendMessage sends a plain-text message to peer (LocalSend "send message":
|
||||||
// Build file metadata keyed by a generated fileId.
|
// one text file whose content rides in the preview field, so nothing is
|
||||||
files := make(map[string]protocol.FileMetadata, len(paths))
|
// uploaded). pin may be empty; supply it when the peer requires one. Errors —
|
||||||
pathByID := make(map[string]string, len(paths))
|
// 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 {
|
for _, p := range paths {
|
||||||
fi, err := os.Stat(p)
|
if _, err := os.Stat(p); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(p), Err: err})
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
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()
|
id := randID()
|
||||||
files[id] = protocol.FileMetadata{
|
files[id] = protocol.FileMetadata{
|
||||||
ID: id,
|
ID: id,
|
||||||
FileName: filepath.Base(p),
|
FileName: it.name,
|
||||||
Size: fi.Size(),
|
Size: it.size,
|
||||||
FileType: mimeType(p),
|
FileType: mimeType(it.path),
|
||||||
}
|
}
|
||||||
pathByID[id] = p
|
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) {
|
||||||
|
// 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
|
||||||
|
// generated fileId. A directory's files carry a relative FileName (e.g.
|
||||||
|
// "Trip/day1/img.jpg") so the receiver can recreate the folder structure.
|
||||||
|
items := s.expand(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
|
||||||
}
|
}
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
return
|
return
|
||||||
|
|
@ -98,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) {
|
||||||
|
|
@ -115,22 +267,97 @@ 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sender) prepareUpload(base string, files map[string]protocol.FileMetadata, pin string) (protocol.PrepareUploadResponse, error) {
|
// fileItem is one concrete file to upload: its path on disk, the relative name
|
||||||
|
// advertised to the peer (carries folder structure), and its size.
|
||||||
|
type fileItem struct {
|
||||||
|
path string
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// expand turns the selected paths into a flat list of files. A regular file is
|
||||||
|
// passed through with its base name. A directory is walked recursively; each
|
||||||
|
// contained file's advertised name is its path relative to the directory's
|
||||||
|
// parent, so the selected folder itself is recreated on the receiver (selecting
|
||||||
|
// "Trip" yields "Trip/day1/img.jpg", …). Unreadable entries are skipped with an
|
||||||
|
// error event rather than aborting the whole transfer.
|
||||||
|
func (s *Sender) expand(paths []string) []fileItem {
|
||||||
|
var items []fileItem
|
||||||
|
for _, p := range paths {
|
||||||
|
fi, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(p), Err: err})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !fi.IsDir() {
|
||||||
|
items = append(items, fileItem{path: p, name: filepath.Base(p), size: fi.Size()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
root := filepath.Dir(filepath.Clean(p)) // parent, so the folder name is kept
|
||||||
|
_ = filepath.WalkDir(p, func(fp string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(fp), Err: err})
|
||||||
|
return nil // skip this entry, keep walking the rest
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(fp), Err: err})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, fp)
|
||||||
|
if err != nil {
|
||||||
|
rel = filepath.Base(fp)
|
||||||
|
}
|
||||||
|
items = append(items, fileItem{path: fp, name: filepath.ToSlash(rel), size: info.Size()})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
@ -138,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)
|
||||||
}
|
}
|
||||||
|
|
@ -148,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()
|
||||||
|
|
||||||
|
|
@ -166,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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
128
internal/client/files_sync_test.go
Normal file
128
internal/client/files_sync_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
124
internal/client/folder_test.go
Normal file
124
internal/client/folder_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"omarchy-send/internal/discovery"
|
||||||
|
"omarchy-send/internal/protocol"
|
||||||
|
"omarchy-send/internal/server"
|
||||||
|
"omarchy-send/internal/transfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// expand must walk a directory and advertise each file with a name relative to
|
||||||
|
// the selected folder's parent, so the folder itself is part of the path.
|
||||||
|
func TestExpandDirectory(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "Trip")
|
||||||
|
mustWrite(t, filepath.Join(dir, "cover.jpg"), "a")
|
||||||
|
mustWrite(t, filepath.Join(dir, "day1", "img.jpg"), "bb")
|
||||||
|
mustWrite(t, filepath.Join(dir, "day1", "notes.txt"), "ccc")
|
||||||
|
|
||||||
|
s := New(protocol.DeviceInfo{})
|
||||||
|
items := s.expand([]string{dir})
|
||||||
|
|
||||||
|
got := make([]string, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
got[i] = it.name
|
||||||
|
}
|
||||||
|
sort.Strings(got)
|
||||||
|
want := []string{"Trip/cover.jpg", "Trip/day1/img.jpg", "Trip/day1/notes.txt"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("expand returned %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("expand[%d] = %q, want %q (all: %v)", i, got[i], want[i], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A regular file path passes through with just its base name.
|
||||||
|
func TestExpandFile(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "single.bin")
|
||||||
|
mustWrite(t, p, "x")
|
||||||
|
items := New(protocol.DeviceInfo{}).expand([]string{p})
|
||||||
|
if len(items) != 1 || items[0].name != "single.bin" {
|
||||||
|
t.Fatalf("expand file = %+v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-to-end: sending a directory recreates its structure on the receiver.
|
||||||
|
func TestSendDirectoryPreservesStructure(t *testing.T) {
|
||||||
|
recvDir := t.TempDir()
|
||||||
|
recvInfo := protocol.DeviceInfo{
|
||||||
|
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53998, 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)
|
||||||
|
|
||||||
|
srcRoot := t.TempDir()
|
||||||
|
dir := filepath.Join(srcRoot, "Trip")
|
||||||
|
files := map[string]string{
|
||||||
|
"cover.jpg": "cover-bytes",
|
||||||
|
"day1/img.jpg": "day1-image",
|
||||||
|
"day2/clip.mov": "day2-clip",
|
||||||
|
"day2/sub/note.md": "nested-note",
|
||||||
|
}
|
||||||
|
for rel, body := range files {
|
||||||
|
mustWrite(t, filepath.Join(dir, filepath.FromSlash(rel)), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"})
|
||||||
|
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
|
||||||
|
sender.Send(peer, []string{dir}, "")
|
||||||
|
|
||||||
|
done := 0
|
||||||
|
deadline := time.After(5 * time.Second)
|
||||||
|
for done < len(files) {
|
||||||
|
select {
|
||||||
|
case ev := <-sender.Events():
|
||||||
|
if ev.Kind == transfer.Error {
|
||||||
|
t.Fatalf("send error: %v", ev.Err)
|
||||||
|
}
|
||||||
|
if ev.Kind == transfer.FileDone {
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("timed out after %d/%d files", done, len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for rel, body := range files {
|
||||||
|
got, err := os.ReadFile(filepath.Join(recvDir, "Trip", filepath.FromSlash(rel)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("missing received %q: %v", rel, err)
|
||||||
|
}
|
||||||
|
if string(got) != body {
|
||||||
|
t.Fatalf("%q content = %q, want %q", rel, got, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustWrite(t *testing.T, path, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
internal/client/message_sync_test.go
Normal file
74
internal/client/message_sync_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/client/message_test.go
Normal file
54
internal/client/message_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/client/prepare_204_test.go
Normal file
43
internal/client/prepare_204_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
88
internal/clipboard/clipboard.go
Normal file
88
internal/clipboard/clipboard.go
Normal 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()
|
||||||
|
}
|
||||||
19
internal/clipboard/clipboard_test.go
Normal file
19
internal/clipboard/clipboard_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
internal/config/config_test.go
Normal file
76
internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
56
internal/discovery/find_test.go
Normal file
56
internal/discovery/find_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
129
internal/discovery/probe_test.go
Normal file
129
internal/discovery/probe_test.go
Normal 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
56
internal/notify/notify.go
Normal 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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
64
internal/server/destpath_test.go
Normal file
64
internal/server/destpath_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDestPathPreservesSubdirs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
got, err := destPath(dir, "Trip/day1/img.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dir, "Trip", "day1", "img.jpg")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("destPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
// Parent directories must already exist so the writer can create the file.
|
||||||
|
if _, err := os.Stat(filepath.Dir(got)); err != nil {
|
||||||
|
t.Fatalf("parent dir not created: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traversal attempts must be neutralised — the result always stays under dir.
|
||||||
|
func TestDestPathRejectsTraversal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, name := range []string{
|
||||||
|
"../escape.txt",
|
||||||
|
"../../etc/passwd",
|
||||||
|
"a/../../../oops.txt",
|
||||||
|
"/abs/path.txt",
|
||||||
|
} {
|
||||||
|
got, err := destPath(dir, name)
|
||||||
|
if err != nil {
|
||||||
|
continue // rejected outright — also acceptable
|
||||||
|
}
|
||||||
|
if got != dir && !strings.HasPrefix(got, dir+string(os.PathSeparator)) {
|
||||||
|
t.Fatalf("destPath(%q) = %q escaped %q", name, got, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A name colliding with an existing file gets a " (n)" suffix; subdir collisions
|
||||||
|
// are resolved within their own directory.
|
||||||
|
func TestDestPathDeDup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
first, _ := destPath(dir, "sub/file.txt")
|
||||||
|
if err := os.WriteFile(first, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := destPath(dir, "sub/file.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second == first {
|
||||||
|
t.Fatalf("expected a de-duplicated path, got %q twice", second)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dir, "sub", "file (1).txt")
|
||||||
|
if second != want {
|
||||||
|
t.Fatalf("dedup = %q, want %q", second, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
internal/server/message_test.go
Normal file
57
internal/server/message_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -50,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.
|
||||||
|
|
@ -62,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()
|
||||||
|
|
@ -121,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)
|
||||||
|
|
@ -184,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
|
||||||
|
|
@ -193,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).
|
||||||
|
|
@ -251,10 +303,10 @@ func (s *Server) writeFile(sess *session, fe *fileEntry, key string, r io.Reader
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
dir := s.receiveDir
|
dir := s.receiveDir
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
dest, err := destPath(dir, fe.meta.FileName)
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
dest := uniquePath(dir, fe.meta.FileName)
|
|
||||||
tmp := dest + ".part"
|
tmp := dest + ".part"
|
||||||
|
|
||||||
f, err := os.Create(tmp)
|
f, err := os.Create(tmp)
|
||||||
|
|
@ -297,22 +349,40 @@ func (s *Server) handleCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uniquePath returns a non-colliding path in dir for the (sanitised) filename.
|
// destPath resolves a safe, non-colliding path under dir for the (possibly
|
||||||
func uniquePath(dir, name string) string {
|
// nested) filename, creating parent directories. Sub-paths are honoured so a
|
||||||
base := filepath.Base(filepath.Clean("/" + name)) // strip any path components / traversal
|
// folder send recreates its structure, but any traversal is neutralised:
|
||||||
if base == "." || base == "/" || base == "" {
|
// cleaning against a leading "/" collapses ".." at the root, and a final
|
||||||
base = "file"
|
// containment check guarantees the result stays within dir.
|
||||||
|
func destPath(dir, name string) (string, error) {
|
||||||
|
rel := strings.TrimPrefix(filepath.Clean("/"+filepath.ToSlash(name)), "/")
|
||||||
|
if rel == "" || rel == "." {
|
||||||
|
rel = "file"
|
||||||
}
|
}
|
||||||
candidate := filepath.Join(dir, base)
|
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
if full != dir && !strings.HasPrefix(full, dir+string(os.PathSeparator)) {
|
||||||
return candidate
|
return "", fmt.Errorf("unsafe destination for %q", name)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return uniqueAt(full), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueAt returns full if free, otherwise inserts " (n)" before the extension
|
||||||
|
// until it finds an unused name in the same directory.
|
||||||
|
func uniqueAt(full string) string {
|
||||||
|
if _, err := os.Stat(full); os.IsNotExist(err) {
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
d := filepath.Dir(full)
|
||||||
|
base := filepath.Base(full)
|
||||||
ext := filepath.Ext(base)
|
ext := filepath.Ext(base)
|
||||||
stem := base[:len(base)-len(ext)]
|
stem := base[:len(base)-len(ext)]
|
||||||
for i := 1; ; i++ {
|
for i := 1; ; i++ {
|
||||||
candidate = filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext))
|
cand := filepath.Join(d, fmt.Sprintf("%s (%d)%s", stem, i, ext))
|
||||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
if _, err := os.Stat(cand); os.IsNotExist(err) {
|
||||||
return candidate
|
return cand
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
internal/tailscale/tailscale.go
Normal file
77
internal/tailscale/tailscale.go
Normal 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
|
||||||
|
}
|
||||||
46
internal/tailscale/tailscale_test.go
Normal file
46
internal/tailscale/tailscale_test.go
Normal 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
111
internal/tsproxy/tsproxy.go
Normal 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
|
||||||
|
}
|
||||||
96
internal/tsproxy/tsproxy_test.go
Normal file
96
internal/tsproxy/tsproxy_test.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,42 +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 "backspace":
|
peer := *m.composeTo
|
||||||
if len(m.staged) > 0 {
|
// Remember the pending message so a PIN prompt can resend it.
|
||||||
m.staged = m.staged[:len(m.staged)-1]
|
m.sendPeer = &peer
|
||||||
}
|
m.sendPaths = nil
|
||||||
return m, nil
|
m.pendingMsg = text
|
||||||
case "S":
|
m.ctrl.SendMessage(peer, text, "")
|
||||||
if len(m.staged) > 0 && m.target != nil && m.ctrl != nil {
|
m.notice = "✉ message sent to " + peer.Info.Alias
|
||||||
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.
|
||||||
|
|
@ -451,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()
|
||||||
|
|
@ -472,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":
|
||||||
|
|
@ -499,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 {
|
||||||
|
|
@ -581,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
|
||||||
|
|
@ -588,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 {
|
||||||
|
|
@ -607,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
|
||||||
|
|
@ -614,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)
|
||||||
|
|
@ -660,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).
|
||||||
|
|
@ -686,12 +1014,16 @@ 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("No files staged — press enter on a file to add it."))
|
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))))
|
||||||
for _, p := range m.staged {
|
for _, p := range m.staged {
|
||||||
b.WriteString("\n" + valueStyle.Render("• "+collapseHome(p)))
|
label := collapseHome(p)
|
||||||
|
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
|
||||||
|
label += "/ (folder)"
|
||||||
|
}
|
||||||
|
b.WriteString("\n" + valueStyle.Render("• "+label))
|
||||||
}
|
}
|
||||||
return border.Render(b.String())
|
return border.Render(b.String())
|
||||||
}
|
}
|
||||||
|
|
@ -712,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"))
|
||||||
|
|
@ -927,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 {
|
||||||
|
|
@ -956,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 · 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"
|
||||||
}
|
}
|
||||||
|
|
@ -999,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
262
internal/tui/send_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
114
internal/tui/view_messages.go
Normal file
114
internal/tui/view_messages.go
Normal 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
320
internal/tui/view_send.go
Normal 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
109
nautilus/omarchy-send.py
Normal 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)]
|
||||||
Loading…
Add table
Reference in a new issue