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)
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 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, 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.
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
@ -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 …
```
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.
@ -285,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).

View file

@ -162,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")
@ -187,7 +187,7 @@ func main() {
cfg.Port = *portFlag
}
if *dirFlag != "" {
cfg.ReceiveDir = *dirFlag
cfg.ReceiveDir = config.ExpandHome(*dirFlag)
}
if *pinFlag != "" {
cfg.PIN = *pinFlag
@ -202,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())
@ -321,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()
@ -364,16 +377,50 @@ func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait ti
}
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])
}

View file

@ -269,7 +269,7 @@ mkdir -p "$CFG_DIR"
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)"
| sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/' | head -n1 || true)"
[ -n "${_rd:-}" ] && RECV_DIR="$_rd"
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
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*:
@ -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
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
agents. To message another device (e.g. to notify the user on their desktop):
**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>…
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. To send files, pass paths instead (`omarchy-send <file>…`) — that
opens the TUI with them staged, so it needs a TTY.
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`, …).
@ -344,10 +353,14 @@ 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 a message to another device from the CLI** (no TUI/TTY,
fine for scripts and agents): `omarchy-send -to "<alias>" -message "<text>"`
(plus `-send-pin <pin>` if the target requires one); exit 0 = delivered. Works
over LAN and Tailscale. Full notes: `~/.config/omarchy-send/AGENTS.md`.
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"
@ -366,7 +379,7 @@ echo " Added an Omarchy-Send section to $CLAUDE_MD."
#
# 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)"
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.
@ -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 " 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 is restarted with its proxy enabled — add this flag to"
echo " however tailscaled is launched (entrypoint, unit, …), keeping the"
echo " existing --state/--socket flags:"
echo
echo " tailscaled --tun=userspace-networking --socks5-server=localhost:1055 …"
echo
echo " omarchy-send then uses the proxy automatically — no env vars needed."
echo " (Explicit HTTPS_PROXY/HTTP_PROXY/NO_PROXY are honoured as overrides.)"
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

View file

@ -137,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

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"
"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"`
@ -106,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
}

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
}
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()
@ -1354,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 {