1// Package clipboard provides a clipboard adapter with Wayland/X11 preference and toolkit fallback.
2package clipboard
3
4import (
5 "bytes"
6 "context"
7 "fmt"
8 "os"
9 "os/exec"
10 "strings"
11
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/logging"
15 "github.com/bnema/puregotk/v4/gdk"
16 "github.com/bnema/puregotk/v4/gio"
17 "github.com/bnema/puregotk/v4/glib"
18 "github.com/rs/zerolog"
19)
20
21type toolkitClipboard interface {
22 ReadText(ctx context.Context) (string, error)
23 WriteText(ctx context.Context, text string) error
24 WriteImage(ctx context.Context, image entity.ImageData) error
25}
26
27// Adapter implements port.Clipboard using wl-clipboard on Wayland, then xclip/xsel on X11.
28// If no system clipboard tool is available, it falls back to the toolkit clipboard.
29type Adapter struct {
30 toolkit toolkitClipboard
31 copyCmd string
32 pasteCmd string
33}
34
35func (a *Adapter) ensureToolkitClipboard() toolkitClipboard {
36 if a == nil {
37 return nil
38 }
39 if a.toolkit == nil {
40 a.toolkit = newToolkitClipboard()
41 }
42 return a.toolkit
43}
44
45var (
46 lookPath = exec.LookPath
47 commandContext = exec.CommandContext
48 newToolkitClipboard = defaultToolkitClipboard
49)
50
51// New creates a new clipboard adapter.
52// Detects Wayland vs X11 and selects appropriate clipboard tool.
53func New() port.Clipboard {
54 a := &Adapter{toolkit: newToolkitClipboard()}
55 a.copyCmd, a.pasteCmd = selectSystemClipboard()
56 return a
57}
58
59func defaultToolkitClipboard() toolkitClipboard {
60 display := gdk.DisplayGetDefault()
61 if display == nil {
62 return nil
63 }
64
65 clipboard := display.GetClipboard()
66 if clipboard == nil {
67 return nil
68 }
69
70 return &gdkToolkitClipboard{clipboard: clipboard}
71}
72
73type gdkToolkitClipboard struct {
74 clipboard *gdk.Clipboard
75}
76
77func (c *gdkToolkitClipboard) WriteText(ctx context.Context, text string) error {
78 return withToolkitClipboard(ctx, func() error {
79 if c == nil || c.clipboard == nil {
80 return fmt.Errorf("toolkit clipboard not available")
81 }
82 c.clipboard.SetText(text)
83 return nil
84 })
85}
86
87func (c *gdkToolkitClipboard) ReadText(ctx context.Context) (string, error) {
88 var text string
89 err := withToolkitClipboard(ctx, func() error {
90 if c == nil || c.clipboard == nil {
91 return fmt.Errorf("toolkit clipboard not available")
92 }
93
94 resultCh := make(chan struct {
95 text string
96 err error
97 }, 1)
98 cb := gio.AsyncReadyCallback(func(_ uintptr, result uintptr, _ uintptr) {
99 asyncResult := &gio.AsyncResultBase{}
100 asyncResult.SetGoPointer(result)
101 readText, readErr := c.clipboard.ReadTextFinish(asyncResult)
102 resultCh <- struct {
103 text string
104 err error
105 }{text: readText, err: readErr}
106 })
107 c.clipboard.ReadTextAsync(nil, &cb, 0)
108
109 mainContext := glib.MainContextDefault()
110 if mainContext == nil {
111 return fmt.Errorf("toolkit main context not available")
112 }
113
114 for {
115 select {
116 case result := <-resultCh:
117 text = result.text
118 return result.err
119 default:
120 }
121 if ctx.Err() != nil {
122 return ctx.Err()
123 }
124 mainContext.Iteration(true)
125 }
126 })
127 return text, err
128}
129
130func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {
131 return withToolkitClipboard(ctx, func() error {
132 if c == nil || c.clipboard == nil {
133 return fmt.Errorf("toolkit clipboard not available")
134 }
135
136 texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes))))
137 if err != nil {
138 return err
139 }
140 if texture == nil {
141 return fmt.Errorf("toolkit clipboard returned nil texture")
142 }
143
144 c.clipboard.SetTexture(texture)
145 return nil
146 })
147}
148
149func withToolkitClipboard(ctx context.Context, fn func() error) error {
150 mainContext := glib.MainContextDefault()
151 if mainContext == nil || mainContext.IsOwner() {
152 return fn()
153 }
154
155 resultCh := make(chan error, 1)
156 cb := glib.SourceFunc(func(_ uintptr) bool {
157 resultCh <- fn()
158 return false
159 })
160 glib.IdleAdd(&cb, 0)
161 select {
162 case err := <-resultCh:
163 return err
164 case <-ctx.Done():
165 return ctx.Err()
166 }
167}
168
169func selectSystemClipboard() (copyCmd, pasteCmd string) {
170 // Check for Wayland first.
171 if os.Getenv("WAYLAND_DISPLAY") != "" {
172 if path, err := lookPath("wl-copy"); err == nil {
173 copyCmd = path
174 if pastePath, err := lookPath("wl-paste"); err == nil {
175 pasteCmd = pastePath
176 }
177 }
178 }
179
180 // Fall back to X11 if Wayland tools are not available.
181 if copyCmd == "" && os.Getenv("DISPLAY") != "" {
182 if path, err := lookPath("xclip"); err == nil {
183 copyCmd = path
184 pasteCmd = path
185 } else if path, err := lookPath("xsel"); err == nil {
186 copyCmd = path
187 pasteCmd = path
188 }
189 }
190
191 return copyCmd, pasteCmd
192}
193
194// WriteText copies text to the clipboard.
195func (a *Adapter) WriteText(ctx context.Context, text string) error {
196 log := logging.FromContext(ctx)
197 var commandErr error
198 var toolkitErr error
199 if a.copyCmd != "" {
200 err := a.writeTextWithCommand(ctx, text, log)
201 if err == nil {
202 return nil
203 }
204 commandErr = err
205 }
206 if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
207 err := toolkit.WriteText(ctx, text)
208 if err == nil {
209 log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success")
210 return nil
211 }
212 toolkitErr = err
213 log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back")
214 }
215 if commandErr != nil {
216 return commandErr
217 }
218 if toolkitErr != nil {
219 log.Error().Err(toolkitErr).Msg("clipboard write failed")
220 return toolkitErr
221 }
222 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
223 log.Error().Err(err).Msg("clipboard write failed")
224 return err
225}
226
227// WriteImage copies image bytes to the clipboard.
228// xsel is not supported because the adapter has no reliable binary image mode
229// for it in this codebase.
230func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error {
231 log := logging.FromContext(ctx)
232 var commandErr error
233 var toolkitErr error
234
235 if len(image.Bytes) == 0 {
236 err := fmt.Errorf("empty image data")
237 log.Error().Err(err).Msg("clipboard image write failed")
238 return err
239 }
240 if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
241 err := toolkit.WriteImage(ctx, image)
242 if err == nil {
243 log.Debug().
244 Str("backend", "toolkit").
245 Str("mime_type", image.MimeType).
246 Int("bytes", len(image.Bytes)).
247 Msg("clipboard image write success")
248 return nil
249 }
250 toolkitErr = err
251 log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")
252 }
253 if a.copyCmd != "" {
254 err := a.writeImageWithCommand(ctx, image, log)
255 if err == nil {
256 return nil
257 }
258 commandErr = err
259 }
260 if commandErr != nil {
261 return commandErr
262 }
263 if toolkitErr != nil {
264 log.Error().Err(toolkitErr).Msg("clipboard image write failed")
265 return toolkitErr
266 }
267 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
268 log.Error().Err(err).Msg("clipboard image write failed")
269 return err
270}
271
272func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error {
273 if a.copyCmd == "" {
274 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
275 log.Error().Err(err).Msg("clipboard write failed")
276 return err
277 }
278
279 var cmd *exec.Cmd
280 if strings.Contains(a.copyCmd, "wl-copy") {
281 cmd = commandContext(ctx, a.copyCmd)
282 } else if strings.Contains(a.copyCmd, "xclip") {
283 cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard")
284 } else if strings.Contains(a.copyCmd, "xsel") {
285 cmd = commandContext(ctx, a.copyCmd, "--clipboard", "--input")
286 } else {
287 err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd)
288 log.Error().Err(err).Msg("clipboard write failed")
289 return err
290 }
291
292 cmd.Stdin = strings.NewReader(text)
293 if err := cmd.Run(); err != nil {
294 err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
295 log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed")
296 return err
297 }
298
299 log.Debug().Str("tool", a.copyCmd).Int("len", len(text)).Msg("clipboard write success")
300 return nil
301}
302
303func (a *Adapter) writeImageWithCommand(ctx context.Context, image entity.ImageData, log *zerolog.Logger) error {
304 if a.copyCmd == "" {
305 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
306 log.Error().Err(err).Msg("clipboard image write failed")
307 return err
308 }
309 mimeType := image.MimeType
310 if mimeType == "" {
311 mimeType = "image/png"
312 }
313
314 var cmd *exec.Cmd
315 if strings.Contains(a.copyCmd, "wl-copy") {
316 cmd = commandContext(ctx, a.copyCmd, "--type", mimeType)
317 } else if strings.Contains(a.copyCmd, "xclip") {
318 cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", mimeType, "-i")
319 } else {
320 err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd)
321 log.Error().Err(err).Msg("clipboard image write failed")
322 return err
323 }
324
325 cmd.Stdin = bytes.NewReader(image.Bytes)
326 if err := cmd.Run(); err != nil {
327 err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
328 log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard image write failed")
329 return err
330 }
331
332 log.Debug().Str("tool", a.copyCmd).Int("bytes", len(image.Bytes)).Msg("clipboard image write success")
333 return nil
334}
335
336func (a *Adapter) readTextWithCommand(ctx context.Context, log *zerolog.Logger) (string, error) {
337 if a.pasteCmd == "" {
338 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
339 log.Error().Err(err).Msg("clipboard read failed")
340 return "", err
341 }
342
343 var cmd *exec.Cmd
344 if strings.Contains(a.pasteCmd, "wl-paste") {
345 cmd = commandContext(ctx, a.pasteCmd, "--no-newline")
346 } else if strings.Contains(a.pasteCmd, "xclip") {
347 cmd = commandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o")
348 } else if strings.Contains(a.pasteCmd, "xsel") {
349 cmd = commandContext(ctx, a.pasteCmd, "--clipboard", "--output")
350 } else {
351 err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd)
352 log.Error().Err(err).Msg("clipboard read failed")
353 return "", err
354 }
355
356 out, err := cmd.Output()
357 if err != nil {
358 err = fmt.Errorf("run clipboard tool %s: %w", a.pasteCmd, err)
359 log.Debug().Err(err).Str("tool", a.pasteCmd).Msg("clipboard read failed")
360 return "", err
361 }
362
363 log.Debug().Str("tool", a.pasteCmd).Int("len", len(out)).Msg("clipboard read success")
364 return string(out), nil
365}
366
367// ReadText reads text from the clipboard.
368func (a *Adapter) ReadText(ctx context.Context) (string, error) {
369 log := logging.FromContext(ctx)
370 var commandErr error
371
372 if a.pasteCmd != "" {
373 text, err := a.readTextWithCommand(ctx, log)
374 if err == nil {
375 return text, nil
376 }
377 commandErr = err
378 log.Debug().Err(err).Msg("system clipboard read failed; falling back")
379 }
380
381 if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
382 text, err := toolkit.ReadText(ctx)
383 if err == nil {
384 log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard read success")
385 return text, nil
386 }
387 log.Debug().Err(err).Msg("toolkit clipboard read failed")
388 if commandErr != nil {
389 return "", commandErr
390 }
391 return "", err
392 }
393
394 if commandErr != nil {
395 return "", commandErr
396 }
397 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
398 log.Error().Err(err).Msg("clipboard read failed")
399 return "", err
400}
401
402// Clear clears the clipboard contents.
403func (a *Adapter) Clear(ctx context.Context) error {
404 return a.WriteText(ctx, "")
405}
406
407// HasText returns true if the clipboard contains text data.
408func (a *Adapter) HasText(ctx context.Context) (bool, error) {
409 text, err := a.ReadText(ctx)
410 if err != nil {
411 // Empty clipboard often returns error, treat as no text
412 return false, nil
413 }
414 return text != "", nil
415}