omarchy-send/internal/clipboard/clipboard.go
28allday 098c34c546 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>
2026-05-27 22:06:24 +01:00

88 lines
2.7 KiB
Go

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