Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39280e5590 | |||
| d68421ac97 | |||
| 2342beff10 | |||
| a93cdcaa83 | |||
| 38f057db67 | |||
| ee9060e6d0 | |||
| 05e70f127d | |||
| 7d6e339dcb | |||
| ff67bd6a71 | |||
| a15440ed20 | |||
| e57c426327 | |||
| 2c058031fd |
17 changed files with 1563 additions and 63 deletions
140
README.md
140
README.md
|
|
@ -10,6 +10,10 @@ 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
|
||||
`/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
|
||||
written to the receive directory, with live progress.
|
||||
- **Send** — pick a peer, then find what to send with a built-in **recursive
|
||||
|
|
@ -47,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
|
||||
Omarchy also adds a floating Walker entry (search **Omarchy-Send**). Override the
|
||||
location with `BIN_DIR=/usr/local/bin`, or pin a version with
|
||||
`OMARCHY_SEND_VERSION=v0.1.0`.
|
||||
Omarchy also adds a floating Walker entry (search **Omarchy-Send**) and the
|
||||
Nautilus right-click integration. Override the location with `BIN_DIR=/usr/local/bin`,
|
||||
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
|
||||
> prefer — it lives at [`install.sh`](install.sh) in this repo.
|
||||
|
|
@ -80,21 +101,30 @@ omarchy-send --no-notify # don't raise desktop notifications on incomin
|
|||
|
||||
### Headless send (no TUI)
|
||||
|
||||
Send a one-off message to a peer by name, with no terminal UI — handy from
|
||||
scripts, cron, or an SSH session with no TTY:
|
||||
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 (waiting up to `--wait`, default 15s),
|
||||
sends the message, prints a one-line result, and exits non-zero if the peer
|
||||
isn't found or the send fails. It starts discovery only — not the receiver — so
|
||||
it's safe to run while another `omarchy-send` instance is up. Both `--to` and
|
||||
`--message` are required; file sending stays in the TUI for now.
|
||||
command discovers the peer over multicast and, like the TUI, directly probes
|
||||
your known peers and online Tailscale peers (waiting up to `--wait`, default
|
||||
15s) — so a remote box added with `+` in the TUI, or any tailnet peer, is a
|
||||
valid `--to` target from a script too. It sends the message 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
|
||||
|
||||
|
|
@ -111,6 +141,92 @@ 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
|
||||
|
|
@ -154,7 +270,7 @@ omarchy-send --auto-accept --pin 2468
|
|||
### Keys
|
||||
|
||||
- `1`–`5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings
|
||||
- Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `r` refresh · `/` filter
|
||||
- Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `+` add a remote device · `r` refresh · `/` filter
|
||||
- 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
|
||||
|
|
@ -182,5 +298,5 @@ desktop client and are not controlled by the sender.
|
|||
|
||||
MIT — see [LICENSE](LICENSE). Omarchy-Send is an independent implementation of
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ import (
|
|||
"omarchy-send/internal/discovery"
|
||||
"omarchy-send/internal/notify"
|
||||
"omarchy-send/internal/server"
|
||||
"omarchy-send/internal/tailscale"
|
||||
"omarchy-send/internal/transfer"
|
||||
"omarchy-send/internal/tui"
|
||||
)
|
||||
|
|
@ -34,6 +36,85 @@ type controller struct {
|
|||
sender *client.Sender
|
||||
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() }
|
||||
|
|
@ -81,7 +162,7 @@ func main() {
|
|||
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); requires -message")
|
||||
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")
|
||||
|
|
@ -106,7 +187,7 @@ func main() {
|
|||
cfg.Port = *portFlag
|
||||
}
|
||||
if *dirFlag != "" {
|
||||
cfg.ReceiveDir = *dirFlag
|
||||
cfg.ReceiveDir = config.ExpandHome(*dirFlag)
|
||||
}
|
||||
if *pinFlag != "" {
|
||||
cfg.PIN = *pinFlag
|
||||
|
|
@ -121,29 +202,27 @@ func main() {
|
|||
cfg.NoNotify = true
|
||||
}
|
||||
|
||||
// Headless one-shot send: resolve the target by alias over discovery, send,
|
||||
// and exit — no TUI, no terminal required. Suitable for scripts and cron.
|
||||
// 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 != "" {
|
||||
if *toFlag == "" || *messageFlag == "" {
|
||||
fmt.Fprintln(os.Stderr, "headless send needs both -to <alias> and -message <text>")
|
||||
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)
|
||||
}
|
||||
os.Exit(runHeadlessSend(cfg, *toFlag, *messageFlag, *sendPINFlag, *waitFlag))
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
os.Exit(runQuickSend(cfg, paths))
|
||||
os.Exit(runQuickSend(cfg, absPaths(args)))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -186,7 +265,9 @@ func main() {
|
|||
// only carries the user preference here.
|
||||
notifyOn := &atomic.Bool{}
|
||||
notifyOn.Store(!cfg.NoNotify)
|
||||
ctrl := controller{disc: disc, sender: sender, srv: srv, notify: notifyOn}
|
||||
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())
|
||||
app.BridgeDiscovery(ctx, disc.Events(), p.Send)
|
||||
|
|
@ -222,7 +303,9 @@ func runQuickSend(cfg config.Config, paths []string) int {
|
|||
|
||||
// No receiver in quick-send mode, so nothing to notify about.
|
||||
notifyOff := &atomic.Bool{}
|
||||
ctrl := controller{disc: disc, sender: sender, srv: nil, notify: notifyOff}
|
||||
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)
|
||||
|
|
@ -236,12 +319,27 @@ func runQuickSend(cfg config.Config, paths []string) int {
|
|||
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 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 line goes to stdout.
|
||||
func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait time.Duration) int {
|
||||
// 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()
|
||||
|
||||
|
|
@ -252,6 +350,11 @@ func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait ti
|
|||
}
|
||||
disc.Announce() // solicit replies immediately rather than waiting a tick
|
||||
|
||||
// Multicast can't cross subnets or the tailnet, so also probe known peers
|
||||
// and online Tailscale peers directly — same as the TUI's device list.
|
||||
rem := &remotes{hosts: cfg.KnownPeers}
|
||||
go watchRemotes(ctx, disc, rem)
|
||||
|
||||
want := strings.TrimSpace(target)
|
||||
fmt.Fprintf(os.Stderr, "Looking for %q on the network (up to %s)…\n", want, wait)
|
||||
|
||||
|
|
@ -268,22 +371,56 @@ func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait ti
|
|||
fmt.Fprintf(os.Stderr, " - %q (%s)\n", p.Info.Alias, p.IP)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "No peers were seen at all — check you're on the same LAN and the target is running omarchy-send / LocalSend.")
|
||||
fmt.Fprintln(os.Stderr, "No peers were seen at all — check the target is running omarchy-send / LocalSend on the same LAN, or is reachable as a known peer / over Tailscale.")
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
sender := client.New(cfg.DeviceInfo())
|
||||
if err := sender.SendMessageSync(peer, message, sendPIN); err != nil {
|
||||
|
||||
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)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Printf("Message sent to %q (%s).\n", peer.Info.Alias, peer.IP)
|
||||
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])
|
||||
}
|
||||
|
|
|
|||
352
install.sh
352
install.sh
|
|
@ -14,11 +14,15 @@
|
|||
# Environment overrides:
|
||||
# BIN_DIR=/usr/local/bin install location (default ~/.local/bin)
|
||||
# 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:
|
||||
# - Headless system: installs the plain `omarchy-send` TUI binary.
|
||||
# - Omarchy desktop: additionally adds a Walker entry that launches it as a
|
||||
# floating TUI (via the stock TUI.float app-id), like the Wi-Fi TUI.
|
||||
# - Local machine (home/LAN): installs the TUI; on Omarchy also adds a Walker
|
||||
# entry + the Nautilus right-click integration.
|
||||
# - 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
|
||||
|
||||
|
|
@ -27,6 +31,7 @@ BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
|
|||
APP_DIR="$HOME/.local/share/applications"
|
||||
BIN="$BIN_DIR/omarchy-send"
|
||||
VERSION="${OMARCHY_SEND_VERSION:-latest}"
|
||||
PORT=53317
|
||||
|
||||
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)"
|
||||
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 ---------------------------------------------------
|
||||
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.mod" ] && command -v go >/dev/null 2>&1; then
|
||||
echo "==> Building omarchy-send from source..."
|
||||
|
|
@ -227,6 +256,323 @@ else
|
|||
echo "==> Headless system — installed as a plain TUI."
|
||||
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
|
||||
case ":$PATH:" in
|
||||
*":$BIN_DIR:"*) : ;;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import (
|
|||
"omarchy-send/internal/discovery"
|
||||
"omarchy-send/internal/protocol"
|
||||
"omarchy-send/internal/transfer"
|
||||
"omarchy-send/internal/tsproxy"
|
||||
)
|
||||
|
||||
// errOpen wraps a failure to open a source file, so the send loop can skip just
|
||||
|
|
@ -60,6 +61,10 @@ func New(self protocol.DeviceInfo) *Sender {
|
|||
// 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,
|
||||
|
|
@ -132,6 +137,74 @@ func (s *Sender) SendMessageSync(peer discovery.Peer, text, pin string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// SendFilesSync uploads the given file/folder paths to peer and blocks until
|
||||
// the whole batch is done, returning the error directly — including
|
||||
// transfer.ErrPinRequired when the peer needs a PIN. Unlike Send it reports
|
||||
// nothing on Events(); it exists for the headless one-shot send path, where
|
||||
// there is no TUI to consume events (the progress events uploadFile emits are
|
||||
// harmlessly dropped). onDone, when non-nil, is called after each file lands,
|
||||
// so the CLI can print per-file progress lines.
|
||||
func (s *Sender) SendFilesSync(ctx context.Context, peer discovery.Peer, paths []string, pin string, onDone func(name string, size int64)) error {
|
||||
// Explicitly named paths that don't exist are a hard error up front — in
|
||||
// the TUI a stat failure is just an event on one staged entry, but a script
|
||||
// passing a wrong path wants a non-zero exit, not a silent skip.
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
items := s.expand(paths)
|
||||
if len(items) == 0 {
|
||||
return errors.New("nothing to send: no readable files under the given paths")
|
||||
}
|
||||
|
||||
files := make(map[string]protocol.FileMetadata, len(items))
|
||||
pathByID := make(map[string]string, len(items))
|
||||
for _, it := range items {
|
||||
id := randID()
|
||||
files[id] = protocol.FileMetadata{
|
||||
ID: id,
|
||||
FileName: it.name,
|
||||
Size: it.size,
|
||||
FileType: mimeType(it.path),
|
||||
}
|
||||
pathByID[id] = it.path
|
||||
}
|
||||
|
||||
base := s.url(peer)
|
||||
prepResp, err := s.prepareUpload(ctx, base, files, pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// An empty token map (204) means the peer accepted but wants nothing
|
||||
// uploaded — e.g. it already has the files. That's success: the loop below
|
||||
// simply finds no tokens to push.
|
||||
var skipped []string
|
||||
for id, token := range prepResp.Files {
|
||||
meta := files[id]
|
||||
key := prepResp.SessionID + ":" + id
|
||||
if err := s.uploadFile(ctx, base, prepResp.SessionID, id, token, key, pathByID[id], meta); err != nil {
|
||||
// A failure to open a local file is specific to that file (it
|
||||
// vanished or lost permissions since staging) — skip it and keep
|
||||
// the batch going, like the TUI path does. Anything else means the
|
||||
// peer/session is gone and can't be resumed, so abort the batch.
|
||||
if errors.Is(err, errOpen) {
|
||||
skipped = append(skipped, meta.FileName)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("upload %q: %w", meta.FileName, err)
|
||||
}
|
||||
if onDone != nil {
|
||||
onDone(meta.FileName, meta.Size)
|
||||
}
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
return fmt.Errorf("could not read: %s", strings.Join(skipped, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
|
||||
// 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
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,11 +6,31 @@ import (
|
|||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"omarchy-send/internal/protocol"
|
||||
"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.
|
||||
type Config struct {
|
||||
Alias string `json:"alias"`
|
||||
|
|
@ -25,6 +45,11 @@ type Config struct {
|
|||
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.
|
||||
CertPEM string `json:"certPem"`
|
||||
KeyPEM string `json:"keyPem"`
|
||||
|
|
@ -101,6 +126,9 @@ func Load() (Config, error) {
|
|||
if cfg.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 == "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import (
|
|||
|
||||
"omarchy-send/internal/dbg"
|
||||
"omarchy-send/internal/protocol"
|
||||
"omarchy-send/internal/tsproxy"
|
||||
)
|
||||
|
||||
// EventKind distinguishes peer lifecycle events.
|
||||
|
|
@ -73,6 +74,11 @@ func New(self protocol.DeviceInfo) *Discoverer {
|
|||
client: &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
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},
|
||||
},
|
||||
},
|
||||
|
|
@ -295,6 +301,67 @@ func (d *Discoverer) reply(ip string, port int, proto string) {
|
|||
_ = 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
|
||||
// emits PeerFound on first sight or when its address changes. Safe for
|
||||
// concurrent use; ignores our own fingerprint.
|
||||
|
|
@ -306,7 +373,13 @@ func (d *Discoverer) NotePeer(info protocol.DeviceInfo, ip string) {
|
|||
|
||||
d.mu.Lock()
|
||||
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.mu.Unlock()
|
||||
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
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")
|
||||
// Cursor starts on the newest (drop.txt was written last). Marking it and
|
||||
// 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 {
|
||||
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 {
|
||||
t.Fatal("expected confirm card to be showing")
|
||||
}
|
||||
m = key(m, "y") // confirm
|
||||
m = key(m, "y") // confirm
|
||||
if m.confirmDel {
|
||||
t.Error("confirm card should be dismissed after delete")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ type Controller interface {
|
|||
SetReceiveDir(string)
|
||||
SetPIN(string)
|
||||
SetNotify(bool)
|
||||
AddKnownPeer(host string) // probe a remote host directly (off-LAN / Tailscale)
|
||||
}
|
||||
|
||||
type screen int
|
||||
|
|
@ -123,6 +124,10 @@ type Model struct {
|
|||
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
|
||||
|
|
@ -199,6 +204,11 @@ func New(cfg config.Config, ctrl Controller, opts ...Option) Model {
|
|||
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 {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
|
|
@ -229,6 +239,7 @@ func New(cfg config.Config, ctrl Controller, opts ...Option) Model {
|
|||
editInputs: editInputs,
|
||||
msgList: ml,
|
||||
composeInput: compose,
|
||||
peerInput: peerInput,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&m)
|
||||
|
|
@ -321,6 +332,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
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":
|
||||
|
|
@ -387,6 +401,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.refreshManage()
|
||||
}
|
||||
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":
|
||||
if m.screen == screenPeers && m.ctrl != nil {
|
||||
m.ctrl.Announce()
|
||||
|
|
@ -547,6 +570,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
}
|
||||
|
||||
if m.addingPeer {
|
||||
var cmd tea.Cmd
|
||||
m.peerInput, cmd = m.peerInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
if m.pending == nil && m.screen == screenPicker {
|
||||
var cmd tea.Cmd
|
||||
m.fzfQuery, cmd = m.fzfQuery.Update(msg)
|
||||
|
|
@ -597,6 +625,33 @@ func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
return m, cmd
|
||||
}
|
||||
|
||||
// updateAddPeer handles the add-remote-device modal. On enter it persists the
|
||||
// host to config's known-peers and asks the controller to probe it now.
|
||||
func (m Model) updateAddPeer(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.addingPeer = false
|
||||
return m, nil
|
||||
case "enter":
|
||||
host := strings.TrimSpace(m.peerInput.Value())
|
||||
if host != "" {
|
||||
if !contains(m.cfg.KnownPeers, host) {
|
||||
m.cfg.KnownPeers = append(m.cfg.KnownPeers, host)
|
||||
_ = m.cfg.Save()
|
||||
}
|
||||
if m.ctrl != nil {
|
||||
m.ctrl.AddKnownPeer(host)
|
||||
}
|
||||
m.notice = "added remote " + host + " — probing…"
|
||||
}
|
||||
m.addingPeer = false
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.peerInput, cmd = m.peerInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// deleteSelectedMessage drops the highlighted message from the list.
|
||||
func (m *Model) deleteSelectedMessage() {
|
||||
it, ok := m.msgList.SelectedItem().(msgItem)
|
||||
|
|
@ -679,7 +734,9 @@ func (m Model) saveEdit() (tea.Model, tea.Cmd) {
|
|||
m.cfg.DeviceModel = alias
|
||||
}
|
||||
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.Save()
|
||||
|
|
@ -848,6 +905,9 @@ func (m Model) View() string {
|
|||
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()))
|
||||
}
|
||||
|
|
@ -984,6 +1044,18 @@ func (m Model) pinView() 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 {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("Incoming files"))
|
||||
|
|
@ -1233,6 +1305,8 @@ func (m Model) footerText() string {
|
|||
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:
|
||||
|
|
@ -1244,7 +1318,7 @@ func (m Model) footerText() string {
|
|||
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:
|
||||
return "enter send-to · m message · v send-clipboard · r refresh · / filter · 1-5 · q quit"
|
||||
return "enter send-to · m message · v clipboard · + add remote · r refresh · / filter · q quit"
|
||||
case m.screen == screenTransfers:
|
||||
return "c clear finished · 1-5 switch · q quit"
|
||||
case m.screen == screenManage:
|
||||
|
|
@ -1282,21 +1356,10 @@ func collapseHome(p string) string {
|
|||
}
|
||||
|
||||
// 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
|
||||
// receive-dir setting.
|
||||
// the inverse of collapseHome; the canonical implementation lives in config so
|
||||
// every consumer of ReceiveDir expands the same way.
|
||||
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
|
||||
return config.ExpandHome(p)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ type fileItem struct {
|
|||
}
|
||||
|
||||
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 }
|
||||
|
||||
// receivedFiles lists the top-level entries in dir, newest first. In-progress
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue