1// Package clipboard streams PNG screenshots to the native Wayland clipboard.
2package clipboard
3
4import (
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "os/exec"
11 "time"
12
13 "github.com/bnema/nefercap/internal/adapters/capturefile"
14 "github.com/bnema/nefercap/internal/ports"
15)
16
17// Native uses wl-copy's default fork: the clipboard owner intentionally survives
18// nefercap. Only the initial handoff is bounded; never use --foreground here.
19type Native struct{}
20
21func New() *Native { return &Native{} }
22
23func (*Native) Copy(ctx context.Context, r io.Reader) error {
24 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
25 defer cancel()
26 if err := ctx.Err(); err != nil {
27 return err
28 }
29 cmd := exec.CommandContext(ctx, "wl-copy", "--type", "image/png")
30 cmd.Stdin = r
31 // Give wl-copy a real descriptor: its forked owner inherits stderr, so
32 // exec's automatic stderr copier would wait for that long-lived owner.
33 rd, wr, err := os.Pipe()
34 if err != nil {
35 return fmt.Errorf("wl-copy stderr: %w", err)
36 }
37 stderr := &limitedError{}
38 drained := make(chan struct{})
39 go func() { _, _ = io.Copy(stderr, rd); close(drained) }()
40 cmd.Stderr = wr
41 cmd.WaitDelay = 200 * time.Millisecond
42 err = cmd.Run()
43 _ = wr.Close()
44 // Drain queued diagnostics, but never wait for the forked owner to close.
45 if deadlineErr := rd.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); deadlineErr != nil {
46 _ = rd.Close()
47 }
48 <-drained
49 _ = rd.Close()
50 if err != nil {
51 if ctx.Err() != nil {
52 return fmt.Errorf("wl-copy: %w", ctx.Err())
53 }
54 if errors.Is(err, exec.ErrNotFound) {
55 return fmt.Errorf("wl-copy not found: install wl-clipboard: %w", err)
56 }
57 return fmt.Errorf("wl-copy: %w: %s", err, stderr.data)
58 }
59 return nil
60}
61
62type limitedError struct{ data []byte }
63
64func (b *limitedError) Write(p []byte) (int, error) {
65 n := len(p)
66 remaining := 4096 - len(b.data)
67 b.data = append(b.data, p[:min(remaining, len(p))]...)
68 return n, nil
69}
70
71// pngStream keeps diagnostic formatters from inspecting a concurrently active pipe.
72type pngStream struct{ io.Reader }
73
74func (pngStream) String() string { return "PNG stream" }
75
76// Writer composes the existing PNG codec and clipboard port. Saved is set only
77// after encoding a valid file, and survives clipboard failures.
78// Each Writer belongs to one screenshot invocation.
79type Writer struct {
80 codec ports.ScreenshotWriter
81 clipboard ports.Clipboard
82 Saved bool
83}
84
85func NewWriter(codec ports.ScreenshotWriter, clipboard ports.Clipboard) *Writer {
86 return &Writer{codec: codec, clipboard: clipboard}
87}
88
89func (w *Writer) Encode(ctx context.Context, out io.Writer, f ports.Frame) error {
90 return w.codec.Encode(ctx, out, f)
91}
92
93func (w *Writer) Save(ctx context.Context, f ports.Frame, path string) (err error) {
94 w.Saved = false
95 if err := ctx.Err(); err != nil {
96 return err
97 }
98 if err := f.Validate(); err != nil {
99 return err
100 }
101 if path == "" {
102 r, out := io.Pipe()
103 encoded := make(chan error, 1)
104 go func() {
105 err := w.codec.Encode(ctx, out, f)
106 _ = out.CloseWithError(err)
107 encoded <- err
108 }()
109 copyErr := w.clipboard.Copy(ctx, pngStream{r})
110 _ = r.CloseWithError(copyErr)
111 return errors.Join(<-encoded, copyErr)
112 }
113 file, reservation, err := capturefile.CreateReadable(path)
114 if err != nil {
115 return err
116 }
117 defer func() { err = errors.Join(err, file.Close()) }()
118 if err = w.codec.Encode(ctx, file, f); err != nil {
119 return errors.Join(err, reservation.Remove())
120 }
121 // Reuse the original descriptor, never reopen a potentially replaced path.
122 w.Saved = true
123 if _, err = file.Seek(0, io.SeekStart); err != nil {
124 return err
125 }
126 return w.clipboard.Copy(ctx, file)
127}