Compare commits

..

No commits in common. "main" and "v0.1.8" have entirely different histories.
main ... v0.1.8

12 changed files with 52 additions and 840 deletions

View file

@ -101,30 +101,21 @@ omarchy-send --no-notify # don't raise desktop notifications on incomin
### Headless send (no TUI) ### Headless send (no TUI)
Send a one-off message, files, or folders to a peer by name, with no terminal Send a one-off message to a peer by name, with no terminal UI — handy from
UI — handy from scripts, cron, AI agents, or an SSH session with no TTY: scripts, cron, or an SSH session with no TTY:
```sh ```sh
omarchy-send --to "Strong Onion" --message "hello" omarchy-send --to "Strong Onion" --message "hello"
omarchy-send --to "Strong Onion" --message "deploy finished" --wait 20s 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" --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 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 command discovers the peer over multicast (waiting up to `--wait`, default 15s),
your known peers and online Tailscale peers (waiting up to `--wait`, default sends the message, prints a one-line result, and exits non-zero if the peer
15s) — so a remote box added with `+` in the TUI, or any tailnet peer, is a isn't found or the send fails. It starts discovery only — not the receiver — so
valid `--to` target from a script too. It sends the message and/or files, it's safe to run while another `omarchy-send` instance is up. Both `--to` and
prints a result line per file, and exits non-zero if the peer isn't found or `--message` are required; file sending stays in the TUI for now.
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 ### Sending files
@ -159,32 +150,6 @@ The receiver already listens on all interfaces, so it's reachable at its Tailsca
IP with nothing else to configure. Sending and receiving both work, because the IP with nothing else to configure. Sending and receiving both work, because the
probe is a two-way handshake (each side learns the other). 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 #### Public-IP boxes: firewall the port
The receiver binds **all interfaces**, so on a box with a public IP, port `53317` The receiver binds **all interfaces**, so on a box with a public IP, port `53317`
@ -298,5 +263,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 or endorsed by the LocalSend project. The terminal UI is built on the not affiliated with the LocalSend project. The terminal UI is built on the
[Charm](https://github.com/charmbracelet) libraries (also MIT). [Charm](https://github.com/charmbracelet) libraries (also MIT).

View file

@ -162,7 +162,7 @@ func main() {
noNotify = flag.Bool("no-notify", false, "don't raise desktop notifications on incoming messages/files") 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>. // 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") toFlag = flag.String("to", "", "headless send: target peer alias to send to (no TUI); requires -message")
messageFlag = flag.String("message", "", "headless send: plain-text message to send to -to") 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") 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") waitFlag = flag.Duration("wait", 15*time.Second, "headless send: how long to wait for the target peer to be discovered")
@ -187,7 +187,7 @@ func main() {
cfg.Port = *portFlag cfg.Port = *portFlag
} }
if *dirFlag != "" { if *dirFlag != "" {
cfg.ReceiveDir = config.ExpandHome(*dirFlag) cfg.ReceiveDir = *dirFlag
} }
if *pinFlag != "" { if *pinFlag != "" {
cfg.PIN = *pinFlag cfg.PIN = *pinFlag
@ -202,27 +202,29 @@ func main() {
cfg.NoNotify = true cfg.NoNotify = true
} }
// Headless one-shot send: resolve the target by alias over discovery, send // 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 // and exit — no TUI, no terminal required. Suitable for scripts and cron.
// terminal required. Suitable for scripts, cron, and AI agents.
if *toFlag != "" || *messageFlag != "" { if *toFlag != "" || *messageFlag != "" {
paths := absPaths(flag.Args()) if *toFlag == "" || *messageFlag == "" {
if *toFlag == "" { fmt.Fprintln(os.Stderr, "headless send needs both -to <alias> and -message <text>")
fmt.Fprintln(os.Stderr, "headless send needs -to <alias> (plus -message <text> and/or file paths)")
os.Exit(2) os.Exit(2)
} }
if *messageFlag == "" && len(paths) == 0 { os.Exit(runHeadlessSend(cfg, *toFlag, *messageFlag, *sendPINFlag, *waitFlag))
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 // Quick-send: any positional arguments are file/folder paths to send (the
// Nautilus right-click integration calls `omarchy-send <paths…>`). Open the // Nautilus right-click integration calls `omarchy-send <paths…>`). Open the
// TUI with them pre-staged, on the device list. // TUI with them pre-staged, on the device list.
if args := flag.Args(); len(args) > 0 { if args := flag.Args(); len(args) > 0 {
os.Exit(runQuickSend(cfg, absPaths(args))) 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))
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@ -319,27 +321,12 @@ func runQuickSend(cfg config.Config, paths []string) int {
return 0 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- // runHeadlessSend discovers the peer whose alias matches target (case-
// insensitively), sends it the given file/folder paths and/or a plain-text // insensitively), sends it a plain-text message, and returns a process exit
// message, and returns a process exit code. It deliberately starts only // code. It deliberately starts only discovery — not the HTTP receiver — so it
// discovery — not the HTTP receiver — so it can run alongside an // can run alongside an already-running instance without fighting over the
// already-running instance without fighting over the listen port. Status goes // listen port. Status goes to stderr; the success line goes to stdout.
// to stderr; the success lines go to stdout. func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait time.Duration) int {
func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait time.Duration, paths []string) int {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -350,11 +337,6 @@ func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait ti
} }
disc.Announce() // solicit replies immediately rather than waiting a tick 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) want := strings.TrimSpace(target)
fmt.Fprintf(os.Stderr, "Looking for %q on the network (up to %s)…\n", want, wait) fmt.Fprintf(os.Stderr, "Looking for %q on the network (up to %s)…\n", want, wait)
@ -371,56 +353,22 @@ func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait ti
fmt.Fprintf(os.Stderr, " - %q (%s)\n", p.Info.Alias, p.IP) fmt.Fprintf(os.Stderr, " - %q (%s)\n", p.Info.Alias, p.IP)
} }
} else { } 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.") 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.")
} }
return 1 return 1
} }
sender := client.New(cfg.DeviceInfo()) sender := client.New(cfg.DeviceInfo())
if err := sender.SendMessageSync(peer, message, sendPIN); err != nil {
reportErr := func(err error) {
switch { switch {
case errors.Is(err, transfer.ErrPinRequired): case errors.Is(err, transfer.ErrPinRequired):
fmt.Fprintf(os.Stderr, "%q requires a PIN — pass it with -send-pin.\n", peer.Info.Alias) fmt.Fprintf(os.Stderr, "%q requires a PIN — pass it with -send-pin.\n", peer.Info.Alias)
default: default:
fmt.Fprintf(os.Stderr, "send to %q (%s) failed: %v\n", peer.Info.Alias, peer.IP, err) fmt.Fprintf(os.Stderr, "send to %q (%s) failed: %v\n", peer.Info.Alias, peer.IP, err)
} }
return 1
} }
if len(paths) > 0 { fmt.Printf("Message sent to %q (%s).\n", peer.Info.Alias, peer.IP)
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 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])
}

View file

@ -269,7 +269,7 @@ mkdir -p "$CFG_DIR"
RECV_DIR="$HOME/Omarchy-Send" RECV_DIR="$HOME/Omarchy-Send"
if [ -f "$CFG_DIR/config.json" ]; then if [ -f "$CFG_DIR/config.json" ]; then
_rd="$(grep -oE '"receiveDir"[[:space:]]*:[[:space:]]*"[^"]*"' "$CFG_DIR/config.json" \ _rd="$(grep -oE '"receiveDir"[[:space:]]*:[[:space:]]*"[^"]*"' "$CFG_DIR/config.json" \
| sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/' | head -n1 || true)" | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/' | head -n1)"
[ -n "${_rd:-}" ] && RECV_DIR="$_rd" [ -n "${_rd:-}" ] && RECV_DIR="$_rd"
fi fi
@ -282,10 +282,6 @@ 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 servers can send **files** and **plain-text messages** to this machine over the
local network or a Tailscale tailnet. It can also send outbound. 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 **Where received files live —** incoming files are saved under the *receive
directory*: directory*:
@ -309,21 +305,6 @@ foreground TUI, not a background daemon. Start it with:
On a headless box, run it inside a TTY (tmux, or `ssh -t`). It listens on TCP 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. 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` **Config:** `~/.config/omarchy-send/config.json`
(keys: `alias`, `receiveDir`, `port`, `autoAccept`, `pin`, `knownPeers`, …). (keys: `alias`, `receiveDir`, `port`, `autoAccept`, `pin`, `knownPeers`, …).
@ -353,14 +334,7 @@ land in `@@RECV_DIR@@`** (authoritative: the `receiveDir` key in
`~/.config/omarchy-send/config.json`). Files still transferring carry a `.part` `~/.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. 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 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** headless). Full notes: `~/.config/omarchy-send/AGENTS.md`.
(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) --> <!-- END omarchy-send (managed by installer) -->
BLK BLK
sed -i "s|@@RECV_DIR@@|$RECV_DIR|g" "$blk" sed -i "s|@@RECV_DIR@@|$RECV_DIR|g" "$blk"
@ -379,7 +353,7 @@ echo " Added an Omarchy-Send section to $CLAUDE_MD."
# #
# Tailscale interface: usually tailscale0, but absent when tailscaled runs in # Tailscale interface: usually tailscale0, but absent when tailscaled runs in
# userspace-networking mode (the default inside containers) — don't hardcode it. # 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)" TS_IFACE="$(ip -o link show 2>/dev/null | grep -oE 'tailscale[0-9]+' | head -n1)"
# Container? Under Docker host-networking the port binds the *host's* stack, and # Container? Under Docker host-networking the port binds the *host's* stack, and
# the firewall belongs on the host, not in this namespace. # the firewall belongs on the host, not in this namespace.
@ -479,100 +453,6 @@ if [ "$MODE" != "remote" ] && [ -n "$PUBLIC_IP" ]; then
echo " curl -sk https://<public-ip>:$PORT/api/localsend/v2/info # should time out" echo " curl -sk https://<public-ip>:$PORT/api/localsend/v2/info # should time out"
fi fi
# ---- userspace-networking Tailscale: outbound proxy check -----------------
# Under tailscaled --tun=userspace-networking (no TUN device — the default in
# unprivileged containers), processes cannot dial OUT to tailnet addresses at
# all; tailscaled's SOCKS5 proxy is the only outbound path. omarchy-send
# auto-detects and uses it at localhost:1055 — so check it's there and say
# exactly what to do when it isn't (receive works either way; send doesn't).
if command -v tailscale >/dev/null 2>&1 && [ -z "$TS_IFACE" ] \
&& tailscale status >/dev/null 2>&1; then
if (exec 3<>/dev/tcp/127.0.0.1/1055) 2>/dev/null; then
exec 3>&- || true
echo
echo "==> Userspace-networking Tailscale detected; SOCKS5 proxy found at"
echo " localhost:1055 — sends to tailnet devices will route through it"
echo " automatically. Nothing to do."
else
echo
echo "⚠ Tailscale is running in userspace-networking mode (no TUN interface)"
echo " and no SOCKS5 proxy is listening on localhost:1055. This box can"
echo " RECEIVE over the tailnet, but CANNOT SEND to tailnet devices until"
echo " tailscaled runs with its proxy enabled."
# Offer to apply the fix: add --socks5-server=localhost:1055 to whatever
# launcher starts tailscaled (e.g. a container entrypoint) and restart it.
# Defaults to NO — non-interactive installs never restart someone else's
# daemon. OMARCHY_SEND_FIX_TAILSCALE=yes|no skips the prompt.
FIX_TS="${OMARCHY_SEND_FIX_TAILSCALE:-}"
case "$FIX_TS" in yes | no) : ;; *)
FIX_TS="no"
if { exec 3<>/dev/tty; } 2>/dev/null; then
printf ' Enable it now? The tailscaled launcher gets the flag added and\n tailscaled is restarted — this briefly drops the tailnet, including\n any tailscale SSH session. [y/N] ' >&3 || true
IFS= read -r _fx <&3 || _fx=""
exec 3>&- 3<&- || true
case "$_fx" in y | Y | yes | Yes | YES) FIX_TS="yes" ;; esac
fi
;;
esac
if [ "$FIX_TS" = "yes" ]; then
# 1. Persist: patch every writable launcher script that starts tailscaled
# in userspace mode (idempotent — skips ones already carrying the flag).
PATCHED=""
while IFS= read -r _f; do
[ -n "$_f" ] || continue
if grep -q "socks5-server" "$_f"; then PATCHED="$_f"; continue; fi
if [ -w "$_f" ] &&
sed -i "s|--tun=userspace-networking|--tun=userspace-networking --socks5-server=localhost:1055|" "$_f" 2>/dev/null; then
PATCHED="$_f"
echo " Patched launcher: $_f"
fi
done < <(grep -rlse '--tun=userspace-networking' \
"$HOME/.local/bin" /usr/local/bin /usr/local/sbin 2>/dev/null || true)
[ -z "$PATCHED" ] &&
echo " No writable tailscaled launcher found — the restart below won't survive a reboot."
# 2. Restart tailscaled now with its current flags + the proxy. Needs the
# rights of whoever owns the daemon (root in most containers).
RESTARTED=0
_pid="$(pgrep -x tailscaled | head -n1 || true)"
if [ -n "$_pid" ] && mapfile -d '' _args <"/proc/$_pid/cmdline" 2>/dev/null &&
[ "${#_args[@]}" -gt 0 ]; then
case " ${_args[*]} " in *socks5-server*) : ;; *) _args+=("--socks5-server=localhost:1055") ;; esac
SUDO=""
[ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
if [ -z "$SUDO" ] || sudo -n true 2>/dev/null; then
$SUDO pkill -x tailscaled 2>/dev/null || true
sleep 1
# shellcheck disable=SC2086 # $SUDO is deliberately word-split (empty or "sudo -n")
($SUDO nohup "${_args[@]}" >"${TMPDIR:-/tmp}/tailscaled-restart.log" 2>&1 &)
sleep 3
if (exec 3<>/dev/tcp/127.0.0.1/1055) 2>/dev/null; then RESTARTED=1; fi
fi
fi
if [ "$RESTARTED" = "1" ]; then
echo " Done — SOCKS5 proxy is up. Tailnet sends now work, no env vars needed"
[ -n "$PATCHED" ] && echo " (and the patched launcher keeps it working across restarts)."
elif [ -n "$PATCHED" ]; then
echo " Launcher patched, but tailscaled couldn't be restarted from here"
echo " (needs root). Restart the container/box and the fix applies itself."
else
echo " Couldn't patch or restart automatically. Add this flag wherever"
echo " tailscaled is launched, keeping its existing flags:"
echo " tailscaled --tun=userspace-networking --socks5-server=localhost:1055 …"
fi
else
echo " Skipped. To fix manually, add this flag wherever tailscaled is"
echo " launched (keeping its existing --state/--socket flags):"
echo " tailscaled --tun=userspace-networking --socks5-server=localhost:1055 …"
echo " omarchy-send then uses the proxy automatically — no env vars needed."
echo " (Or re-run the installer with OMARCHY_SEND_FIX_TAILSCALE=yes.)"
fi
fi
fi
echo echo
case ":$PATH:" in case ":$PATH:" in
*":$BIN_DIR:"*) : ;; *":$BIN_DIR:"*) : ;;

View file

@ -27,7 +27,6 @@ import (
"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 // errOpen wraps a failure to open a source file, so the send loop can skip just
@ -61,10 +60,6 @@ func New(self protocol.DeviceInfo) *Sender {
// and waiting for response headers after the body is sent. // and waiting for response headers after the body is sent.
Timeout: 0, Timeout: 0,
Transport: &http.Transport{ 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}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,
@ -137,74 +132,6 @@ func (s *Sender) SendMessageSync(peer discovery.Peer, text, pin string) error {
return err return err
} }
// SendFilesSync uploads the given file/folder paths to peer and blocks until
// the whole batch is done, returning the error directly — including
// transfer.ErrPinRequired when the peer needs a PIN. Unlike Send it reports
// nothing on Events(); it exists for the headless one-shot send path, where
// there is no TUI to consume events (the progress events uploadFile emits are
// harmlessly dropped). onDone, when non-nil, is called after each file lands,
// so the CLI can print per-file progress lines.
func (s *Sender) SendFilesSync(ctx context.Context, peer discovery.Peer, paths []string, pin string, onDone func(name string, size int64)) error {
// Explicitly named paths that don't exist are a hard error up front — in
// the TUI a stat failure is just an event on one staged entry, but a script
// passing a wrong path wants a non-zero exit, not a silent skip.
for _, p := range paths {
if _, err := os.Stat(p); err != nil {
return err
}
}
items := s.expand(paths)
if len(items) == 0 {
return errors.New("nothing to send: no readable files under the given paths")
}
files := make(map[string]protocol.FileMetadata, len(items))
pathByID := make(map[string]string, len(items))
for _, it := range items {
id := randID()
files[id] = protocol.FileMetadata{
ID: id,
FileName: it.name,
Size: it.size,
FileType: mimeType(it.path),
}
pathByID[id] = it.path
}
base := s.url(peer)
prepResp, err := s.prepareUpload(ctx, base, files, pin)
if err != nil {
return err
}
// An empty token map (204) means the peer accepted but wants nothing
// uploaded — e.g. it already has the files. That's success: the loop below
// simply finds no tokens to push.
var skipped []string
for id, token := range prepResp.Files {
meta := files[id]
key := prepResp.SessionID + ":" + id
if err := s.uploadFile(ctx, base, prepResp.SessionID, id, token, key, pathByID[id], meta); err != nil {
// A failure to open a local file is specific to that file (it
// vanished or lost permissions since staging) — skip it and keep
// the batch going, like the TUI path does. Anything else means the
// peer/session is gone and can't be resumed, so abort the batch.
if errors.Is(err, errOpen) {
skipped = append(skipped, meta.FileName)
continue
}
return fmt.Errorf("upload %q: %w", meta.FileName, err)
}
if onDone != nil {
onDone(meta.FileName, meta.Size)
}
}
if len(skipped) > 0 {
return fmt.Errorf("could not read: %s", strings.Join(skipped, ", "))
}
return nil
}
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) { func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
// A new transfer to a peer supersedes any still-running one to the same // 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 // peer: cancel it so a half-finished old batch can't carry on once the user

View file

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

View file

@ -6,31 +6,11 @@ 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"`
@ -126,9 +106,6 @@ func Load() (Config, error) {
if cfg.ReceiveDir == "" { if cfg.ReceiveDir == "" {
cfg.ReceiveDir = d.ReceiveDir cfg.ReceiveDir = d.ReceiveDir
} }
// Normalise a ~-form receive dir to absolute; Load persists below, so the
// stored value is unambiguous from then on.
cfg.ReceiveDir = ExpandHome(cfg.ReceiveDir)
if cfg.DeviceType == "" { if cfg.DeviceType == "" {
cfg.DeviceType = d.DeviceType cfg.DeviceType = d.DeviceType
} }

View file

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

View file

@ -18,7 +18,6 @@ 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.
@ -74,11 +73,6 @@ 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},
}, },
}, },
@ -344,12 +338,6 @@ func (d *Discoverer) Probe(ctx context.Context, host string) error {
return lastErr 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. // 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. // It handles bare IPv6 by requiring the [::]:port form for a custom port.
func hostPort(host string) (string, int) { func hostPort(host string) (string, int) {
@ -373,13 +361,7 @@ 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]
// Never downgrade a routable address to loopback: behind a changed := !existed || prev.IP != ip || prev.Info.Alias != info.Alias
// userspace-networking tailscaled, inbound registers all appear to come
// from 127.0.0.1 — recording that would make us "reply" to ourselves.
if existed && isLoopback(ip) && !isLoopback(prev.IP) {
peer.IP = prev.IP
}
changed := !existed || prev.IP != peer.IP || prev.Info.Alias != info.Alias
d.peers[info.Fingerprint] = peer d.peers[info.Fingerprint] = peer
d.mu.Unlock() d.mu.Unlock()

View file

@ -7,7 +7,6 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"omarchy-send/internal/protocol" "omarchy-send/internal/protocol"
) )
@ -47,44 +46,6 @@ func TestProbeRegistersPeer(t *testing.T) {
} }
} }
// 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) { func TestProbeUnreachableErrors(t *testing.T) {
d := New(protocol.DeviceInfo{Fingerprint: "self-fp"}) d := New(protocol.DeviceInfo{Fingerprint: "self-fp"})
// 127.0.0.1:1 — nothing listening; both https and http should fail fast. // 127.0.0.1:1 — nothing listening; both https and http should fail fast.
@ -93,32 +54,6 @@ func TestProbeUnreachableErrors(t *testing.T) {
} }
} }
// 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) { func TestHostPortDefaults(t *testing.T) {
if h, p := hostPort("colossus"); h != "colossus" || p != protocol.DefaultPort { if h, p := hostPort("colossus"); h != "colossus" || p != protocol.DefaultPort {
t.Errorf("hostPort(bare) = %q,%d; want colossus,%d", h, p, protocol.DefaultPort) t.Errorf("hostPort(bare) = %q,%d; want colossus,%d", h, p, protocol.DefaultPort)

View file

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

View file

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

View file

@ -734,9 +734,7 @@ func (m Model) saveEdit() (tea.Model, tea.Cmd) {
m.cfg.DeviceModel = alias m.cfg.DeviceModel = alias
} }
if dir != "" { if dir != "" {
// Expand a typed ~-form immediately so the live server and the saved m.cfg.ReceiveDir = dir
// 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()
@ -1356,10 +1354,21 @@ 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; the canonical implementation lives in config so // the inverse of collapseHome and tolerates the ~-form a user may type into the
// every consumer of ReceiveDir expands the same way. // receive-dir setting.
func expandHome(p string) string { func expandHome(p string) string {
return config.ExpandHome(p) 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
} }
func truncate(s string, n int) string { func truncate(s string, n int) string {