Clipboard send/copy, PIN for messages, and quiet stdlib logging

- Messages to a PIN-protected peer now prompt for the PIN and retry, like
  file sends (was a silent failure). SendMessage takes a pin argument and the
  TUI tracks whether a pending send is a message or files.
- Copy a received message to the clipboard with `y` (in the Messages tab or
  while reading one).
- Send the clipboard as a message with `v` on a device: it opens the compose
  box pre-filled with the clipboard text to review before sending.
- New internal/clipboard package shells out to wl-clipboard / xclip / xsel,
  and falls back to tmux's paste buffer when running inside tmux on a headless
  box (tmux load-buffer -w also reaches the outer clipboard via set-clipboard,
  which omaterm enables by default). No system tool -> "clipboard unavailable".
- Route the standard logger to the debug log (or discard) at startup so stray
  stdlib logging — e.g. net/http's "unsolicited response on idle channel"
  notice after a peer sends a late response — can't paint over the TUI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-05-27 22:06:24 +01:00
parent 7ab3f905ee
commit 098c34c546
8 changed files with 228 additions and 28 deletions

View file

@ -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 picker, and upload them with progress, rate and ETA. Folders are sent
recursively with their structure preserved on the receiver. recursively with their structure preserved on the receiver.
- **Messages** — send a plain-text message to a peer (LocalSend-compatible) and - **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) - **Manage** — browse the receive folder, mark received files (or whole folders)
and delete the ones you no longer want, behind a confirmation prompt. and delete the ones you no longer want, behind a confirmation prompt.
- **HTTPS** — generates a self-signed certificate whose fingerprint matches the - **HTTPS** — generates a self-signed certificate whose fingerprint matches the
@ -94,12 +97,13 @@ omarchy-send --auto-accept --pin 2468
### Keys ### Keys
- `1``5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings - `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 - Send picker: `enter` stage a file · `a` add the current folder · `backspace` unstage · `S` send · `esc` back
- Incoming prompt: `y` accept · `n` reject - Incoming prompt: `y` accept · `n` reject
- Transfers: `c` clear finished - Transfers: `c` clear finished
- Messages: `enter` read the full message · `d` delete it (incoming messages - Messages: `enter` read the full message · `y` copy it to the clipboard · `d`
arrive automatically, with a footer notice) delete it (incoming messages arrive automatically, with a footer notice)
- Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one - 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 under the cursor) · `r` refresh · `/` filter — deletion asks to confirm first
- Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept - Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept

View file

@ -7,6 +7,7 @@ import (
"crypto/tls" "crypto/tls"
"flag" "flag"
"fmt" "fmt"
"log"
"os" "os"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -14,6 +15,7 @@ import (
"omarchy-send/internal/app" "omarchy-send/internal/app"
"omarchy-send/internal/client" "omarchy-send/internal/client"
"omarchy-send/internal/config" "omarchy-send/internal/config"
"omarchy-send/internal/dbg"
"omarchy-send/internal/discovery" "omarchy-send/internal/discovery"
"omarchy-send/internal/server" "omarchy-send/internal/server"
"omarchy-send/internal/tui" "omarchy-send/internal/tui"
@ -28,7 +30,9 @@ type controller struct {
func (c controller) Announce() { c.disc.Announce() } 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) 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) 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) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) }
func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) } func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) }
func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) } func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) }
@ -52,6 +56,11 @@ func main() {
) )
flag.Parse() 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() cfg, err := config.Load()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err) fmt.Fprintf(os.Stderr, "config: %v\n", err)

View file

@ -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": // SendMessage sends a plain-text message to peer (LocalSend "send message":
// one text file whose content rides in the preview field, so nothing is // one text file whose content rides in the preview field, so nothing is
// uploaded). Errors are reported on Events() as an outgoing Error. // uploaded). pin may be empty; supply it when the peer requires one. Errors —
func (s *Sender) SendMessage(peer discovery.Peer, text string) { // including ErrPinRequired — are reported on Events() as an outgoing Error.
go s.sendMessage(peer, text) 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) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel() defer cancel()
@ -115,7 +116,7 @@ func (s *Sender) sendMessage(peer discovery.Peer, text string) {
Preview: text, 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) 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}) s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: "message", Err: err})
} }

View file

@ -33,7 +33,7 @@ func TestSendMessageEndToEnd(t *testing.T) {
sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"}) sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"} 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 { select {
case m := <-srv.Messages(): case m := <-srv.Messages():

View file

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

View file

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

View file

@ -4,6 +4,7 @@ package dbg
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"sync" "sync"
"time" "time"
@ -27,6 +28,18 @@ func setup() {
f = file 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. // Logf appends a timestamped line to the debug log if $OMARCHY_SEND_LOG is set.
func Logf(format string, args ...any) { func Logf(format string, args ...any) {
once.Do(setup) once.Do(setup)

View file

@ -21,6 +21,7 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"omarchy-send/internal/app" "omarchy-send/internal/app"
"omarchy-send/internal/clipboard"
"omarchy-send/internal/config" "omarchy-send/internal/config"
"omarchy-send/internal/discovery" "omarchy-send/internal/discovery"
"omarchy-send/internal/server" "omarchy-send/internal/server"
@ -32,7 +33,7 @@ import (
type Controller interface { type Controller interface {
Announce() Announce()
Send(peer discovery.Peer, paths []string, pin string) Send(peer discovery.Peer, paths []string, pin string)
SendMessage(peer discovery.Peer, text string) SendMessage(peer discovery.Peer, text, pin string)
SetAutoAccept(bool) SetAutoAccept(bool)
SetAlias(string) SetAlias(string)
SetReceiveDir(string) SetReceiveDir(string)
@ -102,11 +103,14 @@ type Model struct {
editFocus int editFocus int
editInputs []textinput.Model // 0=alias, 1=receive dir, 2=pin editInputs []textinput.Model // 0=alias, 1=receive dir, 2=pin
// PIN prompt state for sending to a PIN-protected peer. // 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 pinInput textinput.Model
showPin bool showPin bool
sendPeer *discovery.Peer sendPeer *discovery.Peer
sendPaths []string sendPaths []string
pendingMsg string
// Messages tab + compose modal. // Messages tab + compose modal.
msgList list.Model msgList list.Model
@ -256,8 +260,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateCompose(msg) return m.updateCompose(msg)
} }
if m.readingMsg != nil { if m.readingMsg != nil {
if k := msg.String(); k == "esc" || k == "q" || k == "enter" { switch msg.String() {
case "esc", "q", "enter":
m.readingMsg = nil 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 return m, nil
} }
@ -353,6 +364,49 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
return m, nil 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": case "a":
if m.screen == screenManage { if m.screen == screenManage {
m.toggleMarkAll() m.toggleMarkAll()
@ -430,8 +484,13 @@ func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "enter": case "enter":
text := strings.TrimSpace(m.composeInput.Value()) text := strings.TrimSpace(m.composeInput.Value())
if text != "" && m.composeTo != nil && m.ctrl != nil { if text != "" && m.composeTo != nil && m.ctrl != nil {
m.ctrl.SendMessage(*m.composeTo, text) peer := *m.composeTo
m.notice = "✉ message sent to " + m.composeTo.Info.Alias // 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.composing = false
m.composeTo = nil 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 { if len(m.staged) > 0 && m.target != nil && m.ctrl != nil {
m.sendPeer = m.target m.sendPeer = m.target
m.sendPaths = m.staged m.sendPaths = m.staged
m.pendingMsg = "" // this is a file send, not a message
m.ctrl.Send(*m.target, m.staged, "") m.ctrl.Send(*m.target, m.staged, "")
m.staged = nil m.staged = nil
m.screen = screenTransfers m.screen = screenTransfers
@ -587,9 +647,15 @@ func (m Model) updatePin(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showPin = false m.showPin = false
m.pinInput.Blur() m.pinInput.Blur()
if pin != "" && m.sendPeer != nil && m.ctrl != nil { if pin != "" && m.sendPeer != nil && m.ctrl != nil {
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.ctrl.Send(*m.sendPeer, m.sendPaths, pin)
m.screen = screenTransfers m.screen = screenTransfers
} }
}
return m, nil return m, nil
case "esc", "ctrl+c": case "esc", "ctrl+c":
m.showPin = false m.showPin = false
@ -1092,7 +1158,7 @@ func (m Model) footerText() string {
case m.composing: case m.composing:
return "enter send · esc cancel" return "enter send · esc cancel"
case m.readingMsg != nil: case m.readingMsg != nil:
return "esc/enter close" return "y copy · esc/enter close"
case m.confirmDel: case m.confirmDel:
return "y/enter delete · n/esc cancel" return "y/enter delete · n/esc cancel"
case m.editing: case m.editing:
@ -1100,13 +1166,13 @@ func (m Model) footerText() string {
case m.screen == screenPicker: case m.screen == screenPicker:
return "enter stage file · a add folder · backspace unstage · S send · esc back" return "enter stage file · a add folder · backspace unstage · S send · esc back"
case m.screen == screenPeers: 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: case m.screen == screenTransfers:
return "c clear finished · 1-5 switch · q quit" return "c clear finished · 1-5 switch · q quit"
case m.screen == screenManage: case m.screen == screenManage:
return "space mark · a all · d delete · r refresh · / filter · 1-5 switch · q quit" return "space mark · a all · d delete · r refresh · / filter · 1-5 switch · q quit"
case m.screen == screenMessages: 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: case m.screen == screenSettings:
return "e edit · a auto-accept · i icons · 1-5 switch · q quit" return "e edit · a auto-accept · i icons · 1-5 switch · q quit"
} }