Compare commits

...

6 commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:32:25 +01:00
8 changed files with 490 additions and 67 deletions

View file

@ -101,24 +101,30 @@ omarchy-send --no-notify # don't raise desktop notifications on incomin
### Headless send (no TUI) ### Headless send (no TUI)
Send a one-off message to a peer by name, with no terminal UI — handy from Send a one-off message, files, or folders to a peer by name, with no terminal
scripts, cron, or an SSH session with no TTY: UI — handy from scripts, cron, AI agents, 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 and, like the TUI, directly probes
your known peers and online Tailscale peers (waiting up to `--wait`, default 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 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, prints a one-line valid `--to` target from a script too. It sends the message and/or files,
result, and exits non-zero if the peer isn't found or the send fails. It starts prints a result line per file, and exits non-zero if the peer isn't found or
discovery only — not the receiver — so it's safe to run while another the send fails. It starts discovery only — not the receiver — so it's safe to
`omarchy-send` instance is up. Both `--to` and `--message` are required; file run while another `omarchy-send` instance is up.
sending stays in the TUI for now.
`--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
@ -168,6 +174,13 @@ the auto-detection. The one thing the box must provide is the proxy itself:
tailscaled --tun=userspace-networking --socks5-server=localhost:1055 … 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` 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 (tailscaled re-dials loopback); omarchy-send keeps a peer's routable address
rather than letting those registers overwrite it. rather than letting those registers overwrite it.
@ -285,5 +298,5 @@ desktop client and are not controlled by the sender.
MIT — see [LICENSE](LICENSE). Omarchy-Send is an independent implementation of MIT — see [LICENSE](LICENSE). Omarchy-Send is an independent implementation of
the published [LocalSend protocol](https://github.com/localsend/protocol); it is the published [LocalSend protocol](https://github.com/localsend/protocol); it is
not affiliated with the LocalSend project. The terminal UI is built on the not affiliated with or endorsed by the LocalSend project. The terminal UI is built on the
[Charm](https://github.com/charmbracelet) libraries (also MIT). [Charm](https://github.com/charmbracelet) libraries (also MIT).

View file

@ -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); 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") 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 = *dirFlag cfg.ReceiveDir = config.ExpandHome(*dirFlag)
} }
if *pinFlag != "" { if *pinFlag != "" {
cfg.PIN = *pinFlag cfg.PIN = *pinFlag
@ -202,29 +202,27 @@ 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
// and exit — no TUI, no terminal required. Suitable for scripts and cron. // 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 != "" {
if *toFlag == "" || *messageFlag == "" { paths := absPaths(flag.Args())
fmt.Fprintln(os.Stderr, "headless send needs both -to <alias> and -message <text>") if *toFlag == "" {
fmt.Fprintln(os.Stderr, "headless send needs -to <alias> (plus -message <text> and/or file paths)")
os.Exit(2) 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 // 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 {
paths := make([]string, 0, len(args)) os.Exit(runQuickSend(cfg, absPaths(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())
@ -321,12 +319,27 @@ 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 a plain-text message, and returns a process exit // insensitively), sends it the given file/folder paths and/or a plain-text
// code. It deliberately starts only discovery — not the HTTP receiver — so it // message, and returns a process exit code. It deliberately starts only
// can run alongside an already-running instance without fighting over the // discovery — not the HTTP receiver — so it can run alongside an
// listen port. Status goes to stderr; the success line goes to stdout. // already-running instance without fighting over the listen port. Status goes
func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait time.Duration) int { // 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()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -364,16 +377,50 @@ func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait ti
} }
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
} }
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 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)" | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/' | head -n1 || true)"
[ -n "${_rd:-}" ] && RECV_DIR="$_rd" [ -n "${_rd:-}" ] && RECV_DIR="$_rd"
fi fi
@ -282,6 +282,10 @@ 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*:
@ -305,15 +309,20 @@ 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 from the CLI** — no TUI, no TTY, works from scripts and **You can SEND messages AND files from the CLI** — no TUI, no TTY, works from
agents. To message another device (e.g. to notify the user on their desktop): 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>" -message "<text>"
omarchy-send -to "<device alias>" <file-or-folder>…
omarchy-send -to "<device alias>" -message "<text>" <file>…
Add `-send-pin <pin>` if the target requires a PIN, and `-wait 30s` to allow Flags must come before the paths. A folder is sent whole (structure recreated
longer for discovery. Works over the LAN and Tailscale alike; exit code 0 means on the receiver). Add `-send-pin <pin>` if the target requires a PIN, and
delivered. To send files, pass paths instead (`omarchy-send <file>…`) — that `-wait 30s` to allow longer for discovery. Works over the LAN and Tailscale
opens the TUI with them staged, so it needs a TTY. 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`, …).
@ -344,10 +353,14 @@ 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 a message to another device from the CLI** (no TUI/TTY, headless). **You can SEND messages and files to another device from the CLI**
fine for scripts and agents): `omarchy-send -to "<alias>" -message "<text>"` (no TUI/TTY, fine for scripts and agents):
(plus `-send-pin <pin>` if the target requires one); exit 0 = delivered. Works `omarchy-send -to "<alias>" -message "<text>"` and/or
over LAN and Tailscale. Full notes: `~/.config/omarchy-send/AGENTS.md`. `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"
@ -366,7 +379,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)" 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 # 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.
@ -485,14 +498,78 @@ if command -v tailscale >/dev/null 2>&1 && [ -z "$TS_IFACE" ] \
echo "⚠ Tailscale is running in userspace-networking mode (no TUN interface)" 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 " 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 " RECEIVE over the tailnet, but CANNOT SEND to tailnet devices until"
echo " tailscaled is restarted with its proxy enabled — add this flag to" echo " tailscaled runs with its proxy enabled."
echo " however tailscaled is launched (entrypoint, unit, …), keeping the"
echo " existing --state/--socket flags:" # Offer to apply the fix: add --socks5-server=localhost:1055 to whatever
echo # launcher starts tailscaled (e.g. a container entrypoint) and restart it.
echo " tailscaled --tun=userspace-networking --socks5-server=localhost:1055 …" # Defaults to NO — non-interactive installs never restart someone else's
echo # daemon. OMARCHY_SEND_FIX_TAILSCALE=yes|no skips the prompt.
echo " omarchy-send then uses the proxy automatically — no env vars needed." FIX_TS="${OMARCHY_SEND_FIX_TAILSCALE:-}"
echo " (Explicit HTTPS_PROXY/HTTP_PROXY/NO_PROXY are honoured as overrides.)" 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
fi fi

View file

@ -137,6 +137,74 @@ 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

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

View file

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

View file

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

View file

@ -734,7 +734,9 @@ func (m Model) saveEdit() (tea.Model, tea.Cmd) {
m.cfg.DeviceModel = alias m.cfg.DeviceModel = alias
} }
if dir != "" { if dir != "" {
m.cfg.ReceiveDir = dir // Expand a typed ~-form immediately so the live server and the saved
// config both carry the absolute path.
m.cfg.ReceiveDir = config.ExpandHome(dir)
} }
m.cfg.PIN = pin m.cfg.PIN = pin
_ = m.cfg.Save() _ = m.cfg.Save()
@ -1354,21 +1356,10 @@ func collapseHome(p string) string {
} }
// expandHome resolves a leading ~ (or ~/) to the user's home directory. It is // expandHome resolves a leading ~ (or ~/) to the user's home directory. It is
// the inverse of collapseHome and tolerates the ~-form a user may type into the // the inverse of collapseHome; the canonical implementation lives in config so
// receive-dir setting. // every consumer of ReceiveDir expands the same way.
func expandHome(p string) string { func expandHome(p string) string {
if p == "~" { return config.ExpandHome(p)
if home, err := os.UserHomeDir(); err == nil {
return home
}
return p
}
if strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, p[2:])
}
}
return p
} }
func truncate(s string, n int) string { func truncate(s string, n int) string {