Skip to content

Commit 01d5418

Browse files
committed
fix(actions): make copy-to-clipboard resilient
Extract clipboard logic into actions/clipboard.go and harden it: - Bias OSC 52 first when SSH_TTY/SSH_CONNECTION is set; native tools would otherwise target the SSH server's clipboard, not the user's. - Bound each native attempt with a 2s timeout and 1s WaitDelay; treat exec.ErrWaitDelay as success since xclip/xsel daemonize and hold the inherited stdin pipe (golang/go#13155). - Gate wl-copy on a live Wayland socket via XDG_RUNTIME_DIR/WAYLAND_DISPLAY. - Wrap OSC 52 for tmux (\$TMUX) and GNU screen (\$STY+screen TERM) using the go-osc52 library so it works under tmux set-clipboard on / screen DCS pass-through. - Skip OSC 52 when the payload exceeds 64 KB (most terminals silently drop oversized sequences) and surface a clear toast pointing to 'w' (save to file). - Emit OSC 52 to stderr instead of stdout so the escape cannot race with the BubbleTea inline renderer. - Restrict the macOS ladder to pbcopy (no Linux tools). - Add unit tests covering the candidate ladder, SSH bias, OSC 52 length cap, tmux/screen wrapping, and WaitDelay handling.
1 parent 926b877 commit 01d5418

5 files changed

Lines changed: 606 additions & 73 deletions

File tree

actions/clipboard.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package actions
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"os"
8+
"os/exec"
9+
"runtime"
10+
"strings"
11+
"time"
12+
13+
osc52 "github.com/aymanbagabas/go-osc52/v2"
14+
"golang.org/x/term"
15+
)
16+
17+
// osc52SafeLimit is the largest plaintext size where OSC 52 is reliably
18+
// accepted across terminals. xterm caps the OSC 52 sequence around 100 KB
19+
// (~74 KB of plaintext); st and older kitty are smaller. 64 KB is a
20+
// safe-everywhere ceiling — above it, fall back to a native tool or to the
21+
// "save to file" keybinding.
22+
const osc52SafeLimit = 64 * 1024
23+
24+
// copyMethod identifies which backend handled a successful copy.
25+
type copyMethod int
26+
27+
const (
28+
copyMethodUnknown copyMethod = iota
29+
copyMethodNative // local clipboard tool (pbcopy/xclip/...)
30+
copyMethodOSC52 // raw OSC 52 escape sequence
31+
copyMethodOSC52Tmux // OSC 52 wrapped for tmux pass-through
32+
copyMethodOSC52Screen // OSC 52 chunked through GNU screen DCS
33+
)
34+
35+
// Test seams. Production code uses the real OS implementations.
36+
var (
37+
osc52Out io.Writer = os.Stderr
38+
osc52IsTTY = func() bool { return term.IsTerminal(int(os.Stderr.Fd())) }
39+
getEnv = os.Getenv
40+
statFile = func(name string) error { _, err := os.Stat(name); return err }
41+
runtimeOS = runtime.GOOS
42+
lookPath = exec.LookPath
43+
nativeRun = realNativeRun
44+
)
45+
46+
// nativeCmd is a single clipboard backend invocation recipe.
47+
type nativeCmd struct {
48+
name string
49+
args []string
50+
stdin bool // true: pipe text via stdin; false: pass text as final argv
51+
}
52+
53+
// copyToClipboard copies text using the most appropriate backend.
54+
//
55+
// Order is environment-aware:
56+
// - In an SSH session (SSH_TTY/SSH_CONNECTION set), OSC 52 first — local
57+
// tools would target the *server's* clipboard, not the user's.
58+
// - Otherwise, native first, then OSC 52 fallback.
59+
//
60+
// Each native attempt is bounded by a per-attempt timeout. OSC 52 sequences
61+
// are emitted to stderr (not stdout) so they cannot race with a BubbleTea
62+
// inline renderer that owns stdout. Both streams reach the same TTY.
63+
func copyToClipboard(ctx context.Context, text string) (copyMethod, error) {
64+
cands := nativeCandidates()
65+
if inSSH() {
66+
if m, ok := tryOSC52(text); ok {
67+
return m, nil
68+
}
69+
if tryNativeClipboard(ctx, cands, text) {
70+
return copyMethodNative, nil
71+
}
72+
} else {
73+
if tryNativeClipboard(ctx, cands, text) {
74+
return copyMethodNative, nil
75+
}
76+
if m, ok := tryOSC52(text); ok {
77+
return m, nil
78+
}
79+
}
80+
return copyMethodUnknown, errors.New("no clipboard tool available")
81+
}
82+
83+
func inSSH() bool {
84+
return getEnv("SSH_TTY") != "" || getEnv("SSH_CONNECTION") != ""
85+
}
86+
87+
// tryOSC52 emits an OSC 52 escape sequence using the go-osc52 library,
88+
// auto-wrapping for tmux ($TMUX) or GNU screen ($STY + $TERM=screen*).
89+
//
90+
// Skips (without writing) when stderr is not a TTY or when the payload exceeds
91+
// osc52SafeLimit — most terminals silently drop oversized sequences, so it is
92+
// better to surface failure than to claim success.
93+
func tryOSC52(text string) (copyMethod, bool) {
94+
if !osc52IsTTY() {
95+
return copyMethodUnknown, false
96+
}
97+
if len(text) > osc52SafeLimit {
98+
return copyMethodUnknown, false
99+
}
100+
101+
seq := osc52.New(text)
102+
method := copyMethodOSC52
103+
switch {
104+
case getEnv("TMUX") != "":
105+
seq = seq.Tmux()
106+
method = copyMethodOSC52Tmux
107+
case getEnv("STY") != "" && strings.HasPrefix(getEnv("TERM"), "screen"):
108+
seq = seq.Screen()
109+
method = copyMethodOSC52Screen
110+
}
111+
if _, err := seq.WriteTo(osc52Out); err != nil {
112+
return copyMethodUnknown, false
113+
}
114+
return method, true
115+
}
116+
117+
// nativeCandidates returns the native-tool ladder, ordered for the current
118+
// environment. Tools whose runtime requirements are clearly absent (e.g.
119+
// wl-copy without a live Wayland socket) are omitted to avoid wasted
120+
// attempts and timeout-bounded hangs.
121+
func nativeCandidates() []nativeCmd {
122+
var c []nativeCmd
123+
124+
switch runtimeOS {
125+
case "darwin":
126+
c = append(c, nativeCmd{name: "pbcopy", stdin: true})
127+
case "linux":
128+
if isWaylandLive() {
129+
c = append(c, nativeCmd{name: "wl-copy", stdin: true})
130+
}
131+
if getEnv("DISPLAY") != "" {
132+
c = append(c,
133+
nativeCmd{name: "xclip", args: []string{"-selection", "clipboard"}, stdin: true},
134+
nativeCmd{name: "xsel", args: []string{"--clipboard", "--input"}, stdin: true},
135+
)
136+
}
137+
c = append(c, nativeCmd{
138+
name: "qdbus",
139+
args: []string{
140+
"org.kde.klipper", "/klipper",
141+
"org.kde.klipper.klipper.setClipboardContents",
142+
},
143+
stdin: false,
144+
})
145+
c = append(c, nativeCmd{name: "termux-clipboard-set", stdin: true})
146+
}
147+
return c
148+
}
149+
150+
func isWaylandLive() bool {
151+
wd := getEnv("WAYLAND_DISPLAY")
152+
if wd == "" {
153+
return false
154+
}
155+
if strings.HasPrefix(wd, "/") {
156+
return statFile(wd) == nil
157+
}
158+
rd := getEnv("XDG_RUNTIME_DIR")
159+
if rd == "" {
160+
// No way to verify the socket; proceed optimistically. The per-attempt
161+
// timeout bounds any misfire.
162+
return true
163+
}
164+
return statFile(rd+"/"+wd) == nil
165+
}
166+
167+
// tryNativeClipboard runs the candidate ladder, returning true on the first
168+
// success. exec.ErrWaitDelay is treated as success: xclip/xsel daemonize and
169+
// hold the inherited stdin pipe open, so the foreground process exits cleanly
170+
// while WaitDelay force-closes pipes — the clipboard already received the data.
171+
func tryNativeClipboard(parent context.Context, cands []nativeCmd, text string) bool {
172+
for _, c := range cands {
173+
if _, err := lookPath(c.name); err != nil {
174+
continue
175+
}
176+
err := nativeRun(parent, c, text)
177+
if err == nil || errors.Is(err, exec.ErrWaitDelay) {
178+
return true
179+
}
180+
}
181+
return false
182+
}
183+
184+
// realNativeRun runs a single clipboard tool with a tight per-attempt timeout
185+
// and WaitDelay so background-forking tools cannot wedge the caller.
186+
func realNativeRun(parent context.Context, c nativeCmd, text string) error {
187+
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
188+
defer cancel()
189+
190+
argv := append([]string{}, c.args...)
191+
if !c.stdin {
192+
argv = append(argv, text)
193+
}
194+
cmd := exec.CommandContext(ctx, c.name, argv...) //nolint:gosec
195+
cmd.WaitDelay = time.Second
196+
cmd.Stdout = io.Discard
197+
cmd.Stderr = io.Discard
198+
if c.stdin {
199+
cmd.Stdin = strings.NewReader(text)
200+
}
201+
return cmd.Run()
202+
}

0 commit comments

Comments
 (0)