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:
parent
7ab3f905ee
commit
098c34c546
8 changed files with 228 additions and 28 deletions
12
README.md
12
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
88
internal/clipboard/clipboard.go
Normal file
88
internal/clipboard/clipboard.go
Normal 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()
|
||||
}
|
||||
19
internal/clipboard/clipboard_test.go
Normal file
19
internal/clipboard/clipboard_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue