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