diff --git a/README.md b/README.md index 1a0ccbd..9ebe4be 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,10 @@ LocalSend mobile and desktop apps on the same LAN, including their default picker, and upload them with progress, rate and ETA. Folders are sent recursively with their structure preserved on the receiver. - **Messages** — send a plain-text message to a peer (LocalSend-compatible) and - read messages others send you in a dedicated Messages tab. + read messages others send you in a dedicated Messages tab. Send the system + clipboard as a message, or copy a received one back to the clipboard (uses + `wl-clipboard`/`xclip`/`xsel`, or tmux's paste buffer when run inside tmux on + a headless box). - **Manage** — browse the receive folder, mark received files (or whole folders) and delete the ones you no longer want, behind a confirmation prompt. - **HTTPS** — generates a self-signed certificate whose fingerprint matches the @@ -94,12 +97,13 @@ omarchy-send --auto-accept --pin 2468 ### Keys - `1`–`5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings -- Peers: `enter` send to the selected peer · `m` message the selected peer · `r` refresh · `/` filter +- Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `r` refresh · `/` filter +- PIN-protected peers: messages prompt for the PIN and retry, just like file sends - Send picker: `enter` stage a file · `a` add the current folder · `backspace` unstage · `S` send · `esc` back - Incoming prompt: `y` accept · `n` reject - Transfers: `c` clear finished -- Messages: `enter` read the full message · `d` delete it (incoming messages - arrive automatically, with a footer notice) +- Messages: `enter` read the full message · `y` copy it to the clipboard · `d` + delete it (incoming messages arrive automatically, with a footer notice) - Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one under the cursor) · `r` refresh · `/` filter — deletion asks to confirm first - Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept diff --git a/cmd/omarchy-send/main.go b/cmd/omarchy-send/main.go index 750b4e1..491ff5c 100644 --- a/cmd/omarchy-send/main.go +++ b/cmd/omarchy-send/main.go @@ -7,6 +7,7 @@ import ( "crypto/tls" "flag" "fmt" + "log" "os" tea "github.com/charmbracelet/bubbletea" @@ -14,6 +15,7 @@ import ( "omarchy-send/internal/app" "omarchy-send/internal/client" "omarchy-send/internal/config" + "omarchy-send/internal/dbg" "omarchy-send/internal/discovery" "omarchy-send/internal/server" "omarchy-send/internal/tui" @@ -28,10 +30,12 @@ type controller struct { func (c controller) Announce() { c.disc.Announce() } func (c controller) Send(p discovery.Peer, paths []string, pin string) { c.sender.Send(p, paths, pin) } -func (c controller) SendMessage(p discovery.Peer, text string) { c.sender.SendMessage(p, text) } -func (c controller) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) } -func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) } -func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) } +func (c controller) SendMessage(p discovery.Peer, text, pin string) { + c.sender.SendMessage(p, text, pin) +} +func (c controller) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) } +func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) } +func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) } // SetAlias updates the alias across all services and re-announces it. func (c controller) SetAlias(alias string) { @@ -52,6 +56,11 @@ func main() { ) flag.Parse() + // The TUI owns the terminal, so keep stray stdlib logging (e.g. net/http's + // "unsolicited response on idle channel" notice) off the screen — route it + // to the debug log when enabled, otherwise discard it. + log.SetOutput(dbg.Writer()) + cfg, err := config.Load() if err != nil { fmt.Fprintf(os.Stderr, "config: %v\n", err) diff --git a/internal/client/client.go b/internal/client/client.go index fa9f6b1..695be23 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -96,12 +96,13 @@ func (s *Sender) Send(peer discovery.Peer, paths []string, pin string) { // SendMessage sends a plain-text message to peer (LocalSend "send message": // one text file whose content rides in the preview field, so nothing is -// uploaded). Errors are reported on Events() as an outgoing Error. -func (s *Sender) SendMessage(peer discovery.Peer, text string) { - go s.sendMessage(peer, text) +// uploaded). pin may be empty; supply it when the peer requires one. Errors — +// including ErrPinRequired — are reported on Events() as an outgoing Error. +func (s *Sender) SendMessage(peer discovery.Peer, text, pin string) { + go s.sendMessage(peer, text, pin) } -func (s *Sender) sendMessage(peer discovery.Peer, text string) { +func (s *Sender) sendMessage(peer discovery.Peer, text, pin string) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() @@ -115,7 +116,7 @@ func (s *Sender) sendMessage(peer discovery.Peer, text string) { Preview: text, }, } - if _, err := s.prepareUpload(ctx, s.url(peer), files, ""); err != nil { + if _, err := s.prepareUpload(ctx, s.url(peer), files, pin); err != nil { dbg.Logf("send message to %s failed: %v", peer.IP, err) s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: "message", Err: err}) } diff --git a/internal/client/message_test.go b/internal/client/message_test.go index 6f0cb7a..b9462d6 100644 --- a/internal/client/message_test.go +++ b/internal/client/message_test.go @@ -33,7 +33,7 @@ func TestSendMessageEndToEnd(t *testing.T) { sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"}) peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"} - sender.SendMessage(peer, "hello over the wire\nsecond line") + sender.SendMessage(peer, "hello over the wire\nsecond line", "") select { case m := <-srv.Messages(): diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 0000000..1a52a45 --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,88 @@ +// Package clipboard reads and writes the system clipboard by shelling out to +// whichever helper is available: wl-clipboard (Wayland), xclip, xsel, or — when +// running inside tmux on a headless box — tmux's own paste buffer (which can +// reach the real clipboard via tmux's set-clipboard/OSC52). The binary stays +// dependency-free and static; these tools are optional, so a box with none +// simply reports the clipboard unavailable. +package clipboard + +import ( + "bytes" + "errors" + "os" + "os/exec" + "strings" +) + +// ErrUnavailable means no supported clipboard helper is reachable. +var ErrUnavailable = errors.New("no clipboard available (need wl-clipboard, xclip, xsel, or tmux)") + +// inTmux reports whether we're running inside a tmux session. +func inTmux() bool { return os.Getenv("TMUX") != "" } + +// candidate is one clipboard helper invocation. eligible gates it beyond the +// binary simply existing on PATH (used to require an active tmux session). +type candidate struct { + bin string + args []string + eligible bool +} + +// Read returns the current clipboard text, or ErrUnavailable if no helper is +// reachable. A single trailing newline is trimmed. System tools take priority; +// tmux's buffer is the fallback for headless sessions. +func Read() (string, error) { + for _, c := range []candidate{ + {"wl-paste", []string{"--no-newline"}, true}, + {"xclip", []string{"-selection", "clipboard", "-o"}, true}, + {"xsel", []string{"--clipboard", "--output"}, true}, + {"tmux", []string{"save-buffer", "-"}, inTmux()}, + } { + if !c.eligible { + continue + } + if _, err := exec.LookPath(c.bin); err != nil { + continue + } + out, err := exec.Command(c.bin, c.args...).Output() + if err != nil { + return "", err + } + return strings.TrimRight(string(out), "\n"), nil + } + return "", ErrUnavailable +} + +// Write sets the clipboard to text, or returns ErrUnavailable if no helper is +// reachable. +func Write(text string) error { + for _, c := range []candidate{ + {"wl-copy", nil, true}, + {"xclip", []string{"-selection", "clipboard"}, true}, + {"xsel", []string{"--clipboard", "--input"}, true}, + // -w also pushes the buffer to the outer terminal's clipboard when + // tmux's set-clipboard is on; we fall back to a plain load on failure. + {"tmux", []string{"load-buffer", "-w", "-"}, inTmux()}, + } { + if !c.eligible { + continue + } + if _, err := exec.LookPath(c.bin); err != nil { + continue + } + if err := run(c.bin, c.args, text); err != nil { + if c.bin == "tmux" { + return run("tmux", []string{"load-buffer", "-"}, text) // older tmux: no -w + } + return err + } + return nil + } + return ErrUnavailable +} + +func run(bin string, args []string, stdin string) error { + cmd := exec.Command(bin, args...) + cmd.Stdin = bytes.NewBufferString(stdin) + return cmd.Run() +} diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go new file mode 100644 index 0000000..5375ea4 --- /dev/null +++ b/internal/clipboard/clipboard_test.go @@ -0,0 +1,19 @@ +package clipboard + +import ( + "errors" + "testing" +) + +// With no helpers reachable on PATH, both operations report ErrUnavailable +// rather than panicking or hanging. (We empty PATH instead of touching the real +// clipboard, so the test is safe to run anywhere.) +func TestUnavailableWithoutTools(t *testing.T) { + t.Setenv("PATH", "") + if _, err := Read(); !errors.Is(err, ErrUnavailable) { + t.Errorf("Read with no tools: got %v, want ErrUnavailable", err) + } + if err := Write("x"); !errors.Is(err, ErrUnavailable) { + t.Errorf("Write with no tools: got %v, want ErrUnavailable", err) + } +} diff --git a/internal/dbg/dbg.go b/internal/dbg/dbg.go index 5110a5c..9565905 100644 --- a/internal/dbg/dbg.go +++ b/internal/dbg/dbg.go @@ -4,6 +4,7 @@ package dbg import ( "fmt" + "io" "os" "sync" "time" @@ -27,6 +28,18 @@ func setup() { f = file } +// Writer returns the debug log file if $OMARCHY_SEND_LOG is set, else io.Discard. +// It's used to redirect the standard logger away from the terminal so stray +// stdlib logging (e.g. net/http's "unsolicited response" notice) can't corrupt +// the TUI; the output is still captured in the debug log when enabled. +func Writer() io.Writer { + once.Do(setup) + if f == nil { + return io.Discard + } + return f +} + // Logf appends a timestamped line to the debug log if $OMARCHY_SEND_LOG is set. func Logf(format string, args ...any) { once.Do(setup) diff --git a/internal/tui/model.go b/internal/tui/model.go index 13c9e58..f3d5565 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -21,6 +21,7 @@ import ( "github.com/charmbracelet/lipgloss" "omarchy-send/internal/app" + "omarchy-send/internal/clipboard" "omarchy-send/internal/config" "omarchy-send/internal/discovery" "omarchy-send/internal/server" @@ -32,7 +33,7 @@ import ( type Controller interface { Announce() Send(peer discovery.Peer, paths []string, pin string) - SendMessage(peer discovery.Peer, text string) + SendMessage(peer discovery.Peer, text, pin string) SetAutoAccept(bool) SetAlias(string) SetReceiveDir(string) @@ -102,11 +103,14 @@ type Model struct { editFocus int editInputs []textinput.Model // 0=alias, 1=receive dir, 2=pin - // PIN prompt state for sending to a PIN-protected peer. - pinInput textinput.Model - showPin bool - sendPeer *discovery.Peer - sendPaths []string + // PIN prompt state for sending to a PIN-protected peer. pendingMsg is set + // (and sendPaths nil) when the pending send is a message rather than files, + // so the PIN retry resends the right thing. + pinInput textinput.Model + showPin bool + sendPeer *discovery.Peer + sendPaths []string + pendingMsg string // Messages tab + compose modal. msgList list.Model @@ -256,8 +260,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateCompose(msg) } if m.readingMsg != nil { - if k := msg.String(); k == "esc" || k == "q" || k == "enter" { + switch msg.String() { + case "esc", "q", "enter": m.readingMsg = nil + case "y": + if err := clipboard.Write(m.readingMsg.Text); err != nil { + m.notice = "clipboard unavailable" + } else { + m.notice = "copied to clipboard" + } } return m, nil } @@ -353,6 +364,49 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return m, nil + case "v": + // Send the clipboard: open compose to the selected device, + // pre-filled with the clipboard text, so it can be reviewed first. + if m.screen == screenPeers { + if it, ok := m.peerList.SelectedItem().(peerItem); ok { + text, err := clipboard.Read() + if err != nil { + m.notice = "clipboard unavailable" + return m, nil + } + if strings.TrimSpace(text) == "" { + m.notice = "clipboard is empty" + return m, nil + } + peer := it.p + m.composeTo = &peer + m.composing = true + m.composeInput.SetValue(text) + m.composeInput.CursorEnd() + m.composeInput.Focus() + return m, textinput.Blink + } + } + return m, nil + case "y": + // Copy the selected/open message to the clipboard. + if m.screen == screenMessages { + var text string + if m.readingMsg != nil { + text = m.readingMsg.Text + } else if it, ok := m.msgList.SelectedItem().(msgItem); ok { + text = it.m.Text + } + if text == "" { + return m, nil + } + if err := clipboard.Write(text); err != nil { + m.notice = "clipboard unavailable" + } else { + m.notice = "copied to clipboard" + } + } + return m, nil case "a": if m.screen == screenManage { m.toggleMarkAll() @@ -430,8 +484,13 @@ func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter": text := strings.TrimSpace(m.composeInput.Value()) if text != "" && m.composeTo != nil && m.ctrl != nil { - m.ctrl.SendMessage(*m.composeTo, text) - m.notice = "✉ message sent to " + m.composeTo.Info.Alias + peer := *m.composeTo + // Remember the pending message so a PIN prompt can resend it. + m.sendPeer = &peer + m.sendPaths = nil + m.pendingMsg = text + m.ctrl.SendMessage(peer, text, "") + m.notice = "✉ message sent to " + peer.Info.Alias } m.composing = false m.composeTo = nil @@ -482,6 +541,7 @@ func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.staged) > 0 && m.target != nil && m.ctrl != nil { m.sendPeer = m.target m.sendPaths = m.staged + m.pendingMsg = "" // this is a file send, not a message m.ctrl.Send(*m.target, m.staged, "") m.staged = nil m.screen = screenTransfers @@ -587,8 +647,14 @@ func (m Model) updatePin(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.showPin = false m.pinInput.Blur() if pin != "" && m.sendPeer != nil && m.ctrl != nil { - m.ctrl.Send(*m.sendPeer, m.sendPaths, pin) - m.screen = screenTransfers + if m.pendingMsg != "" { + m.ctrl.SendMessage(*m.sendPeer, m.pendingMsg, pin) + m.notice = "✉ message sent to " + m.sendPeer.Info.Alias + m.pendingMsg = "" + } else { + m.ctrl.Send(*m.sendPeer, m.sendPaths, pin) + m.screen = screenTransfers + } } return m, nil case "esc", "ctrl+c": @@ -1092,7 +1158,7 @@ func (m Model) footerText() string { case m.composing: return "enter send · esc cancel" case m.readingMsg != nil: - return "esc/enter close" + return "y copy · esc/enter close" case m.confirmDel: return "y/enter delete · n/esc cancel" case m.editing: @@ -1100,13 +1166,13 @@ func (m Model) footerText() string { case m.screen == screenPicker: return "enter stage file · a add folder · backspace unstage · S send · esc back" case m.screen == screenPeers: - return "enter send-to · m message · r refresh · / filter · 1-5 switch · q quit" + return "enter send-to · m message · v send-clipboard · r refresh · / filter · 1-5 · q quit" case m.screen == screenTransfers: return "c clear finished · 1-5 switch · q quit" case m.screen == screenManage: return "space mark · a all · d delete · r refresh · / filter · 1-5 switch · q quit" case m.screen == screenMessages: - return "enter read · d delete · 1-5 switch · q quit" + return "enter read · y copy · d delete · 1-5 switch · q quit" case m.screen == screenSettings: return "e edit · a auto-accept · i icons · 1-5 switch · q quit" }