Compare commits

..

3 commits

Author SHA1 Message Date
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
8 changed files with 408 additions and 56 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

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
} }
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) 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

@ -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"

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 {