okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfix(tui): paste clipboard images over ssh with OSC 552282ecef8

main
7.3 KiB266 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"bytes"
6	"context"
7	"encoding/base64"
8	"encoding/hex"
9	"errors"
10	"fmt"
11	"image"
12	_ "image/jpeg"
13	_ "image/png"
14	"io"
15	"os"
16	"os/exec"
17	"runtime"
18	"strings"
19	"time"
20
21	tea "charm.land/bubbletea/v2"
22	"github.com/atotto/clipboard"
23)
24
25const (
26	MaxClipboardImageBytes = 5 * 1024 * 1024
27	MaxClipboardImageEdge  = 16384
28)
29
30func execClipboardCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
31	cmd := exec.CommandContext(ctx, name, args...)
32	stdout, err := cmd.StdoutPipe()
33	if err != nil {
34		return nil, err
35	}
36	defer stdout.Close()
37	if err = cmd.Start(); err != nil {
38		return nil, err
39	}
40	// Killing the command does not close stdout held open by a descendant.
41	stopClosingStdout := context.AfterFunc(ctx, func() { _ = stdout.Close() })
42	defer stopClosingStdout()
43
44	// The extra byte detects oversized output without buffering the rest.
45	limited := io.LimitReader(stdout, MaxClipboardImageBytes+1)
46	buf, err := io.ReadAll(limited)
47	if err != nil || len(buf) > MaxClipboardImageBytes {
48		// Once reading stops, a child still writing to the pipe could block Wait.
49		_ = cmd.Process.Kill()
50	}
51	waitErr := cmd.Wait()
52
53	if len(buf) > MaxClipboardImageBytes {
54		return nil, fmt.Errorf("clipboard image exceeds %d byte limit", MaxClipboardImageBytes)
55	}
56	if ctx.Err() != nil {
57		return buf, ctx.Err()
58	}
59	if err != nil {
60		return buf, err
61	}
62	return buf, waitErr
63}
64
65// CopyText puts text on the clipboard of the machine albedo runs on and asks
66// the terminal to put it on its own with OSC 52. Those differ when you reach
67// albedo over ssh or attach to its session from another device, and only the
68// terminal's is the one you paste from; a terminal without OSC 52 still has
69// the local copy.
70func CopyText(text string) tea.Cmd {
71	return tea.Batch(tea.SetClipboard(text), func() tea.Msg {
72		_ = clipboard.WriteAll(text)
73		return nil
74	})
75}
76
77func isRemote() bool {
78	return os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" || os.Getenv("MOSH_CONNECTION") != ""
79}
80
81func ClipboardHasImage() bool {
82	if isRemote() {
83		return false
84	}
85
86	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
87	defer cancel()
88
89	switch runtime.GOOS {
90	case "darwin":
91		if _, err := exec.LookPath("pngpaste"); err == nil {
92			if out, err := execClipboardCommand(ctx, "pngpaste", "-b"); err == nil && len(out) == 0 {
93				return true
94			}
95		}
96		out, err := execClipboardCommand(ctx, "osascript", "-e", "clipboard info")
97		if err != nil {
98			return false
99		}
100		s := string(out)
101		return strings.Contains(s, "«class PNGf»") || strings.Contains(s, "JPEG picture") || strings.Contains(s, "TIFF picture")
102
103	case "linux":
104		// Wayland first: under a compositor both may be set.
105		probes := []struct {
106			env, tool string
107			args      []string
108		}{
109			{"WAYLAND_DISPLAY", "wl-paste", []string{"--list-types"}},
110			{"DISPLAY", "xclip", []string{"-selection", "clipboard", "-t", "TARGETS", "-o"}},
111		}
112		for _, probe := range probes {
113			if os.Getenv(probe.env) == "" {
114				continue
115			}
116			if _, err := exec.LookPath(probe.tool); err != nil {
117				continue
118			}
119			out, err := execClipboardCommand(ctx, probe.tool, probe.args...)
120			if err == nil && strings.Contains(string(out), "image/") {
121				return true
122			}
123		}
124		return false
125	}
126
127	return false
128}
129
130func parseAppleScriptHex(out []byte) ([]byte, error) {
131	s, ok1 := strings.CutPrefix(strings.TrimSpace(string(out)), "«data ")
132	s, ok2 := strings.CutSuffix(s, "»")
133	if !ok1 || !ok2 {
134		return nil, errors.New("not an AppleScript hex payload")
135	}
136	fields := strings.Fields(s)
137	if len(fields) == 0 {
138		return nil, errors.New("empty AppleScript data")
139	}
140	// Skip the 4-char ostype code (e.g. PNGf, JPEG)
141	if len(fields[0]) <= 4 {
142		return nil, errors.New("missing hex payload in AppleScript data")
143	}
144	return hex.DecodeString(fields[0][4:])
145}
146
147func ReadClipboardImage() (*daemon.ImageAttachment, error) {
148	if isRemote() {
149		return nil, errors.New("remote clipboard image unsupported")
150	}
151
152	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
153	defer cancel()
154
155	var data []byte
156	var err error
157
158	switch runtime.GOOS {
159	case "darwin":
160		if _, errPath := exec.LookPath("pngpaste"); errPath == nil {
161			data, err = execClipboardCommand(ctx, "pngpaste", "-")
162		} else {
163			rawOut, errCmd := execClipboardCommand(ctx, "osascript", "-e", "get the clipboard as «class PNGf»")
164			if errCmd != nil {
165				return nil, errCmd
166			}
167			if len(rawOut) > MaxClipboardImageBytes {
168				return nil, fmt.Errorf("clipboard image too large: %d bytes (max %d)", len(rawOut), MaxClipboardImageBytes)
169			}
170			data, err = parseAppleScriptHex(rawOut)
171		}
172
173	case "linux":
174		if os.Getenv("WAYLAND_DISPLAY") != "" {
175			data, err = execClipboardCommand(ctx, "wl-paste", "-t", "image/png")
176		} else if os.Getenv("DISPLAY") != "" {
177			data, err = execClipboardCommand(ctx, "xclip", "-selection", "clipboard", "-t", "image/png", "-o")
178		} else {
179			return nil, errors.New("no display or clipboard tool available")
180		}
181
182	default:
183		return nil, fmt.Errorf("clipboard images are not supported on %s", runtime.GOOS)
184	}
185
186	if err != nil {
187		return nil, fmt.Errorf("could not read clipboard image: %w", err)
188	}
189
190	return clipboardImage(data)
191}
192
193func clipboardImage(data []byte) (*daemon.ImageAttachment, error) {
194	if len(data) == 0 {
195		return nil, errors.New("clipboard contains no image data")
196	}
197
198	if len(data) > MaxClipboardImageBytes {
199		return nil, fmt.Errorf("clipboard image too large: %d bytes (max %d)", len(data), MaxClipboardImageBytes)
200	}
201
202	// Strictly validate config - rejects truncated or 0-dimension images
203	cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
204	if err != nil {
205		return nil, fmt.Errorf("clipboard image is invalid or incomplete: %w", err)
206	}
207	if cfg.Width <= 0 || cfg.Height <= 0 {
208		return nil, errors.New("invalid image dimensions: zero or negative")
209	}
210	if cfg.Width > MaxClipboardImageEdge || cfg.Height > MaxClipboardImageEdge {
211		return nil, fmt.Errorf("image dimensions too large: %dx%d (max edge %d)", cfg.Width, cfg.Height, MaxClipboardImageEdge)
212	}
213
214	mime := daemon.ImagePNG
215	switch format {
216	case "jpeg", "jpg":
217		mime = daemon.ImageJPEG
218	case "webp":
219		mime = daemon.ImageWEBP
220	}
221
222	encoded := base64.StdEncoding.EncodeToString(data)
223	return &daemon.ImageAttachment{
224		ImageMetadata: daemon.ImageMetadata{
225			MimeType: mime,
226			Width:    cfg.Width,
227			Height:   cfg.Height,
228			Bytes:    len(data),
229		},
230		Data: encoded,
231	}, nil
232}
233
234type ClipboardImagePastedMsg struct {
235	Err        error
236	Image      *daemon.ImageAttachment
237	SessionID  string
238	Generation int64
239	// thumb is set once transmit has handed the image to the terminal.
240	thumb    *thumbnail
241	transmit string
242	// hint says why the terminal shows no thumbnail when a setting would fix it.
243	hint string
244}
245
246func PasteClipboardImageCmd(sessionID string, gen int64) tea.Cmd {
247	return func() tea.Msg {
248		if !ClipboardHasImage() {
249			return ClipboardImagePastedMsg{SessionID: sessionID, Generation: gen}
250		}
251		img, err := ReadClipboardImage()
252		return clipboardImagePasted(sessionID, gen, img, err)
253	}
254}
255
256func clipboardImagePasted(sessionID string, gen int64, img *daemon.ImageAttachment, err error) ClipboardImagePastedMsg {
257	msg := ClipboardImagePastedMsg{SessionID: sessionID, Generation: gen, Image: img, Err: err}
258	if img != nil {
259		g := terminalGraphics()
260		if thumb, transmit, ok := transmitThumbnail(g, *img); ok {
261			msg.thumb, msg.transmit = &thumb, transmit
262		}
263		msg.hint = g.hint
264	}
265	return msg
266}