1//go:build linux
2
3package wayland
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "os"
10 "sync"
11 "syscall"
12 "time"
13
14 "github.com/bnema/nefergui/internal/edit"
15 "github.com/bnema/wlturbo"
16 "github.com/bnema/wlturbo/protocol/core"
17 "github.com/bnema/wlturbo/wl"
18 "golang.org/x/sys/unix"
19)
20
21const (
22 mimeUTF8 = "text/plain;charset=utf-8"
23 mimeX11 = "UTF8_STRING"
24 mimePlain = "text/plain"
25 transferTimeout = 3 * time.Second
26)
27
28// ClipboardResult is delivered through the session event queue, never from
29// the reader into the editor. Data may contain invalid UTF-8; edit validates it.
30type ClipboardResult struct {
31 Data []byte
32 Err error
33}
34type ClipboardEvent struct {
35 kind string
36 offer *core.DataOffer
37 mime string
38 source *core.DataSource
39}
40
41// Clipboard is the asynchronous Wayland clipboard adapter. The synchronous
42// edit.Clipboard.ReadText contract cannot be used on the UI loop: callers must
43// use ReadTextAsync and dispatch ClipboardResult on their next frame.
44type Clipboard struct {
45 manager *core.DataDeviceManager
46 version uint32 // negotiated wl_data_device_manager version
47 device *core.DataDevice
48 offer *core.DataOffer
49 dnd *core.DataOffer
50 pending map[*core.DataOffer]map[string]bool
51 offers map[uint32]*core.DataOffer
52 mimes map[string]bool
53 source *core.DataSource
54 serial func() uint32
55 post func(ClipboardEvent)
56 closed bool
57 stop context.CancelFunc
58 workers sync.WaitGroup
59 mu sync.Mutex
60}
61
62var _ edit.AsyncClipboard = (*Clipboard)(nil)
63
64// NewClipboard binds the optional manager at its announced version and creates
65// one data device for seat. Call from the session loop after seat binding.
66func NewClipboard(d *wl.Display, seat *core.Seat, serial func() uint32, post func(ClipboardEvent)) (*Clipboard, error) {
67 if seat == nil || d == nil {
68 return nil, nil
69 }
70 manager := core.NewDataDeviceManager(d.Context())
71 version, err := d.Registry().BindNegotiated(core.DataDeviceManagerInterface, 3, manager)
72 if err != nil {
73 d.Context().Unregister(manager)
74 if errors.Is(err, wlturbo.ErrGlobalNotFound) {
75 return nil, nil
76 }
77 return nil, err
78 }
79 device, err := manager.GetDataDevice(seat)
80 if err != nil {
81 d.Context().Unregister(manager)
82 return nil, err
83 }
84 c := &Clipboard{manager: manager, version: version, device: device, serial: serial, post: post, pending: make(map[*core.DataOffer]map[string]bool), offers: make(map[uint32]*core.DataOffer)}
85 device.OnDataOffer(func(o *core.DataOffer) {
86 c.mu.Lock()
87 if c.closed {
88 c.mu.Unlock()
89 _ = o.Destroy()
90 return
91 }
92 c.offers[o.ID()] = o
93 c.mu.Unlock()
94 c.post(ClipboardEvent{kind: "offer", offer: o})
95 o.OnOffer(func(m string) { c.post(ClipboardEvent{kind: "mime", offer: o, mime: m}) })
96 })
97 device.OnSelection(func(id uint32) { c.post(ClipboardEvent{kind: "selection", offer: c.findOffer(id)}) })
98 // Drag-and-drop is unsupported in V1; retain its offer until leave/drop.
99 device.OnEnter(func(_ uint32, _ uint32, _, _ wl.Fixed, id uint32) {
100 c.post(ClipboardEvent{kind: "enter", offer: c.findOffer(id)})
101 })
102 device.OnLeave(func() { c.post(ClipboardEvent{kind: "leave"}) })
103 device.OnDrop(func() { c.post(ClipboardEvent{kind: "drop"}) })
104 return c, nil
105}
106
107// findOffer only resolves IDs from reader-owned notifications. It does not
108// mutate clipboard state; the loop decides which offer is current.
109func (c *Clipboard) findOffer(id uint32) *core.DataOffer {
110 if id == 0 {
111 return nil
112 }
113 c.mu.Lock()
114 defer c.mu.Unlock()
115 return c.offers[id]
116}
117
118// ApplyClipboard handles reader events on the session loop.
119func (c *Clipboard) ApplyClipboard(ev ClipboardEvent) {
120 switch ev.kind {
121 case "offer":
122 c.pending[ev.offer] = make(map[string]bool)
123 case "mime":
124 if m, ok := c.pending[ev.offer]; ok {
125 m[ev.mime] = true
126 } else if ev.offer == c.offer {
127 c.mimes[ev.mime] = true
128 }
129 case "selection":
130 if c.offer != nil && c.offer != ev.offer {
131 c.destroyOffer(c.offer)
132 }
133 c.offer = ev.offer
134 c.mimes = c.pending[ev.offer]
135 if c.mimes == nil {
136 c.mimes = make(map[string]bool)
137 }
138 delete(c.pending, ev.offer)
139 for o := range c.pending {
140 if o == c.dnd {
141 continue
142 }
143 if o != ev.offer {
144 c.destroyOffer(o)
145 delete(c.pending, o)
146 }
147 }
148 case "enter":
149 c.clearDnD()
150 c.dnd = ev.offer
151 delete(c.pending, ev.offer)
152 case "leave", "drop":
153 c.clearDnD()
154 case "cancelled":
155 if c.source == ev.source {
156 _ = c.source.Destroy()
157 c.source = nil
158 }
159 }
160}
161func (c *Clipboard) clearDnD() {
162 if c.dnd != nil && c.dnd != c.offer {
163 c.destroyOffer(c.dnd)
164 }
165 c.dnd = nil
166}
167func (c *Clipboard) destroyOffer(o *core.DataOffer) {
168 c.mu.Lock()
169 delete(c.offers, o.ID())
170 c.mu.Unlock()
171 _ = o.Destroy()
172}
173func (c *Clipboard) bestMIME() string {
174 for _, m := range []string{mimeUTF8, mimeX11, mimePlain} {
175 if c.mimes[m] {
176 return m
177 }
178 }
179 return ""
180}
181
182// ReadTextAsync must be called on the loop. The compositor receives the write
183// end; a worker reads at most limit+1 bytes. The caller posts the callback to
184// its UI event queue. Concurrent reads cancel the previous transfer. Close
185// cancels and joins reads, not callbacks: a callback racing Close may still run
186// after Close returns. Callers must discard results when shutting down.
187func (c *Clipboard) ReadTextAsync(ctx context.Context, limit int, deliver func([]byte, error)) error {
188 if c == nil || c.offer == nil {
189 return errors.New("clipboard unavailable")
190 }
191 mime := c.bestMIME()
192 if mime == "" {
193 return errors.New("no text MIME offered")
194 }
195 if limit < 0 || limit > edit.MaxClipboardBytes {
196 limit = edit.MaxClipboardBytes
197 }
198 fd := make([]int, 2)
199 if err := unix.Pipe2(fd, unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
200 return err
201 }
202 r := os.NewFile(uintptr(fd[0]), "clipboard-read")
203 if err := c.offer.Receive(mime, fd[1]); err != nil {
204 _ = syscall.Close(fd[1])
205 _ = r.Close()
206 return err
207 }
208 // WLTurbo closes the sent FD after a successful send; do not close it twice.
209 ctx, cancel := context.WithTimeout(ctx, transferTimeout)
210 c.mu.Lock()
211 if c.closed {
212 c.mu.Unlock()
213 cancel()
214 _ = r.Close()
215 return errors.New("clipboard closed")
216 }
217 if c.stop != nil {
218 c.stop()
219 }
220 c.stop = cancel
221 c.workers.Add(1)
222 c.mu.Unlock()
223 go func() {
224 defer c.workers.Done()
225 defer cancel()
226 defer r.Close()
227 // readClipboard polls ctx every 50ms; closing r from another goroutine
228 // while it is read would race on the descriptor.
229 data, e := readClipboard(ctx, r, limit)
230 if ctx.Err() != nil {
231 e = ctx.Err()
232 }
233 c.mu.Lock()
234 open := !c.closed
235 c.mu.Unlock()
236 if !open || ctx.Err() != nil {
237 return
238 }
239 // Arbitrary callbacks cannot be forcibly interrupted. They are not
240 // joined by Close: a blocked UI enqueue must not stall shutdown.
241 go deliver(data, e)
242 }()
243 return nil
244}
245
246// readClipboard polls a nonblocking pipe so cancellation and timeouts are
247// effective even if the compositor never closes its write end.
248func readClipboard(ctx context.Context, r *os.File, limit int) ([]byte, error) {
249 data := make([]byte, 0, min(limit, 4096))
250 buf := make([]byte, 4096)
251 for {
252 if err := ctx.Err(); err != nil {
253 return nil, err
254 }
255 n, e := unix.Read(int(r.Fd()), buf[:min(len(buf), limit+1-len(data))])
256 if n > 0 {
257 data = append(data, buf[:n]...)
258 if len(data) > limit {
259 return nil, fmt.Errorf("clipboard exceeds %d bytes", limit)
260 }
261 }
262 if e == nil && n == 0 {
263 return data, nil
264 }
265 if e == nil {
266 continue
267 }
268 if e != unix.EAGAIN && e != unix.EINTR {
269 return nil, e
270 }
271 poll := []unix.PollFd{{Fd: int32(r.Fd()), Events: unix.POLLIN | unix.POLLHUP}}
272 _, e = unix.Poll(poll, 50)
273 if e != nil && e != unix.EINTR {
274 return nil, e
275 }
276 }
277}
278
279// ReadText deliberately refuses to block the UI loop. Use ReadTextAsync.
280func (c *Clipboard) ReadText(int) ([]byte, error) {
281 return nil, errors.New("wayland clipboard requires ReadTextAsync")
282}
283
284func (c *Clipboard) WriteText(data []byte) error {
285 if c == nil || c.device == nil {
286 return errors.New("clipboard unavailable")
287 }
288 c.mu.Lock()
289 closed := c.closed
290 c.mu.Unlock()
291 if closed {
292 return errors.New("clipboard closed")
293 }
294 if len(data) > edit.MaxClipboardBytes {
295 return errors.New("clipboard too large")
296 }
297 serial := c.serial()
298 if serial == 0 {
299 return errors.New("no keyboard selection serial")
300 }
301 source, err := c.manager.CreateDataSource()
302 if err != nil {
303 return err
304 }
305 for _, m := range []string{mimeUTF8, mimeX11, mimePlain} {
306 if err = source.Offer(m); err != nil {
307 _ = source.Destroy()
308 return err
309 }
310 }
311 payload := append([]byte(nil), data...)
312 source.OnSend(func(m string, fd *wl.OwnedFD) {
313 n, e := fd.Take()
314 if e != nil {
315 return
316 }
317 if m != mimeUTF8 && m != mimeX11 && m != mimePlain {
318 _ = os.NewFile(uintptr(n), "selection").Close()
319 return
320 }
321 go writeSelection(n, payload)
322 })
323 source.OnCancelled(func() { c.post(ClipboardEvent{kind: "cancelled", source: source}) })
324 if err = c.device.SetSelection(source, serial); err != nil {
325 _ = source.Destroy()
326 return err
327 }
328 if c.source != nil {
329 _ = c.source.Destroy()
330 }
331 c.source = source
332 return nil
333}
334func writeSelection(fd int, data []byte) {
335 f := os.NewFile(uintptr(fd), "selection")
336 defer f.Close()
337 _ = unix.SetNonblock(fd, true)
338 deadline := time.Now().Add(transferTimeout)
339 for len(data) > 0 && time.Now().Before(deadline) {
340 n, err := unix.Write(fd, data)
341 if n > 0 {
342 data = data[n:]
343 }
344 if err != nil && err != unix.EAGAIN && err != unix.EINTR {
345 return
346 }
347 if n == 0 || err == unix.EAGAIN {
348 _, _ = unix.Poll([]unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}}, 50)
349 }
350 }
351}
352func (c *Clipboard) Close() {
353 if c == nil {
354 return
355 }
356 c.mu.Lock()
357 c.closed = true
358 if c.stop != nil {
359 c.stop()
360 }
361 c.mu.Unlock()
362 c.workers.Wait() // reads poll cancellation every 50ms (transfer has a 3s deadline)
363 c.clearDnD()
364 if c.offer != nil {
365 c.destroyOffer(c.offer)
366 }
367 for o := range c.pending {
368 if o != c.offer {
369 c.destroyOffer(o)
370 }
371 }
372 if c.source != nil {
373 _ = c.source.Destroy()
374 }
375 if c.device != nil {
376 if c.version >= 2 {
377 _ = c.device.Release()
378 } else {
379 if ctx := c.device.Context(); ctx != nil {
380 ctx.Unregister(c.device)
381 }
382 }
383 }
384 // wl_data_device_manager has no destructor below version 4: sending
385 // release is a protocol error, so only forget the proxy locally.
386 if c.manager != nil {
387 if ctx := c.manager.Context(); ctx != nil {
388 ctx.Unregister(c.manager)
389 }
390 }
391}