detached 99731da

feat(wayland): add native wlr-screencopy source

commit
99731da9ff189a0c4b427a09b95ba6efa74df83d
author
brice · · 2026-09-30 08:22 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
3cdbdaa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +1852 -0
A internal/adapters/wayland/compositor_test.go +535 -0
@@ -0,0 +1,535 @@
1+package wayland
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "net"
7+ "path/filepath"
8+ "sync"
9+ "syscall"
10+ "testing"
11+
12+ "golang.org/x/sys/unix"
13+)
14+
15+// This file implements the compositor side of the Wayland wire protocol over a
16+// real Unix socket: it decodes the requests the Source sends (including
17+// SCM_RIGHTS descriptors), maps the client's shm pools and answers with
18+// encoded events. It is a protocol peer, not a substitute for a project type.
19+
20+const (
21+ kindDisplay = iota + 1
22+ kindRegistry
23+ kindOutput
24+ kindShm
25+ kindPool
26+ kindBuffer
27+ kindScreencopy
28+ kindFrame
29+)
30+
31+const (
32+ globalShm = 1000
33+ globalScreencopy = 1001
34+)
35+
36+type outputSpec struct {
37+ name string
38+ width, height uint32
39+ scale int32
40+ version uint32
41+}
42+
43+// announce is one zwlr_screencopy_frame_v1.buffer event.
44+type announce struct{ format, width, height, stride uint32 }
45+
46+type compositorConfig struct {
47+ outputs []outputSpec
48+ screencopyVersion uint32 // 0 selects 3
49+ noScreencopy bool
50+ announce []announce // empty: derived from the output or region
51+ flags uint32
52+ hold bool // do not answer copy until completePending
53+ failCapture bool
54+ removeOnCapture bool // global_remove of the output, then failed
55+ silent bool // accept the connection and never answer
56+ failCopies int // answer this many copy requests with failed
57+ readyEarly bool // send ready right after the buffer events, before any copy
58+ noShmOffer bool // send only buffer_done: no wl_shm buffer is offered
59+ duplicateGlobal bool // announce the first output global twice
60+}
61+
62+type poolMap struct{ data []byte }
63+
64+type bufferInfo struct {
65+ pool *poolMap
66+ offset int
67+ width, height, stride, format uint32
68+}
69+
70+type compositor struct {
71+ t testing.TB
72+ path string
73+ ln net.Listener
74+ cfg compositorConfig
75+
76+ mu sync.Mutex
77+ conn *net.UnixConn
78+ pools int
79+ buffers int
80+ destroyedBuffers int
81+ captures int
82+ copies int
83+ fdsReceived int
84+ lastRegion [4]int32
85+ lastRegionSet bool
86+ seq uint32
87+ pending struct {
88+ frame uint32
89+ buffer *bufferInfo
90+ }
91+ hasPending bool
92+ copyEvent chan struct{}
93+ done chan struct{}
94+
95+ wmu sync.Mutex
96+ out [4096]byte
97+ reg uint32
98+}
99+
100+func startCompositor(t testing.TB, cfg compositorConfig) *compositor {
101+ t.Helper()
102+ if len(cfg.outputs) == 0 {
103+ cfg.outputs = []outputSpec{{name: "TEST-1", width: 8, height: 4, scale: 1, version: 4}}
104+ }
105+ if cfg.screencopyVersion == 0 {
106+ cfg.screencopyVersion = 3
107+ }
108+ dir, err := filepath.EvalSymlinks(t.TempDir())
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ ln, err := net.Listen("unix", filepath.Join(dir, "wl-test"))
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ c := &compositor{t: t, path: ln.Addr().String(), ln: ln, cfg: cfg, copyEvent: make(chan struct{}, 64), done: make(chan struct{})}
117+ go c.serve()
118+ t.Cleanup(func() {
119+ ln.Close()
120+ c.mu.Lock()
121+ if c.conn != nil {
122+ c.conn.Close()
123+ }
124+ c.mu.Unlock()
125+ <-c.done
126+ })
127+ return c
128+}
129+
130+func (c *compositor) stat(f func(*compositor) int) int {
131+ c.mu.Lock()
132+ defer c.mu.Unlock()
133+ return f(c)
134+}
135+
136+func (c *compositor) serve() {
137+ defer close(c.done)
138+ nc, err := c.ln.Accept()
139+ if err != nil {
140+ return
141+ }
142+ conn := nc.(*net.UnixConn)
143+ c.mu.Lock()
144+ c.conn = conn
145+ c.mu.Unlock()
146+ defer conn.Close()
147+ if c.cfg.silent {
148+ buf := make([]byte, 64)
149+ for {
150+ if _, err := conn.Read(buf); err != nil {
151+ return
152+ }
153+ }
154+ }
155+ c.loop(conn)
156+}
157+
158+type objects struct {
159+ kinds map[uint32]int
160+ outputs map[uint32]int
161+ pools map[uint32]*poolMap
162+ buffers map[uint32]*bufferInfo
163+ frames map[uint32]*frameState
164+}
165+
166+type frameState struct {
167+ output int
168+ w, h uint32
169+}
170+
171+func (c *compositor) loop(conn *net.UnixConn) {
172+ objs := &objects{
173+ kinds: map[uint32]int{1: kindDisplay}, outputs: map[uint32]int{}, pools: map[uint32]*poolMap{},
174+ buffers: map[uint32]*bufferInfo{}, frames: map[uint32]*frameState{},
175+ }
176+ buf := make([]byte, 0, 1<<16)
177+ chunk := make([]byte, 1<<15)
178+ oob := make([]byte, unix.CmsgSpace(4*16))
179+ var fds []int
180+ defer func() {
181+ for _, fd := range fds {
182+ unix.Close(fd)
183+ }
184+ }()
185+ for {
186+ n, oobn, _, _, err := conn.ReadMsgUnix(chunk, oob)
187+ if oobn > 0 {
188+ msgs, perr := syscall.ParseSocketControlMessage(oob[:oobn])
189+ if perr != nil {
190+ c.t.Errorf("control message: %v", perr)
191+ return
192+ }
193+ for i := range msgs {
194+ got, perr := syscall.ParseUnixRights(&msgs[i])
195+ if perr != nil {
196+ c.t.Errorf("rights: %v", perr)
197+ return
198+ }
199+ fds = append(fds, got...)
200+ c.mu.Lock()
201+ c.fdsReceived += len(got)
202+ c.mu.Unlock()
203+ }
204+ }
205+ buf = append(buf, chunk[:max(n, 0)]...)
206+ for len(buf) >= 8 {
207+ size := int(binary.LittleEndian.Uint32(buf[4:]) >> 16)
208+ if size < 8 || len(buf) < size {
209+ break
210+ }
211+ id := binary.LittleEndian.Uint32(buf)
212+ op := uint16(binary.LittleEndian.Uint32(buf[4:]))
213+ if !c.handle(objs, id, op, buf[8:size], &fds) {
214+ return
215+ }
216+ buf = buf[:copy(buf, buf[size:])]
217+ }
218+ if err != nil {
219+ return
220+ }
221+ }
222+}
223+
224+func word(b []byte, i int) uint32 { return binary.LittleEndian.Uint32(b[i*4:]) }
225+
226+func (c *compositor) handle(o *objects, id uint32, op uint16, body []byte, fds *[]int) bool {
227+ switch o.kinds[id] {
228+ case kindDisplay:
229+ switch op {
230+ case 0: // sync
231+ cb := word(body, 0)
232+ c.send(cb, 0, 0)
233+ c.send(1, 1, cb)
234+ case 1: // get_registry
235+ c.wmu.Lock()
236+ c.reg = word(body, 0)
237+ c.wmu.Unlock()
238+ o.kinds[word(body, 0)] = kindRegistry
239+ c.advertise()
240+ }
241+ case kindRegistry:
242+ name := word(body, 0)
243+ n := int(word(body, 1))
244+ iface := string(body[8 : 8+n-1])
245+ i := 8 + (n+3)&^3
246+ version, newID := word(body, i/4), word(body, i/4+1)
247+ c.bind(o, name, iface, version, newID)
248+ case kindOutput:
249+ if op == 0 {
250+ c.send(1, 1, id)
251+ delete(o.kinds, id)
252+ }
253+ case kindShm:
254+ if op == 0 {
255+ if len(*fds) == 0 {
256+ c.t.Error("create_pool without descriptor")
257+ return false
258+ }
259+ fd := (*fds)[0]
260+ *fds = (*fds)[1:]
261+ size := int(word(body, 1))
262+ data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
263+ unix.Close(fd)
264+ if err != nil {
265+ c.t.Errorf("server mmap: %v", err)
266+ return false
267+ }
268+ pid := word(body, 0)
269+ o.kinds[pid] = kindPool
270+ o.pools[pid] = &poolMap{data: data}
271+ c.mu.Lock()
272+ c.pools++
273+ c.mu.Unlock()
274+ }
275+ case kindPool:
276+ switch op {
277+ case 0:
278+ bid := word(body, 0)
279+ o.kinds[bid] = kindBuffer
280+ o.buffers[bid] = &bufferInfo{pool: o.pools[id], offset: int(word(body, 1)), width: word(body, 2), height: word(body, 3), stride: word(body, 4), format: word(body, 5)}
281+ c.mu.Lock()
282+ c.buffers++
283+ c.mu.Unlock()
284+ case 1:
285+ c.send(1, 1, id)
286+ delete(o.kinds, id)
287+ }
288+ case kindBuffer:
289+ if op == 0 {
290+ c.mu.Lock()
291+ c.destroyedBuffers++
292+ c.mu.Unlock()
293+ c.send(1, 1, id)
294+ delete(o.kinds, id)
295+ delete(o.buffers, id)
296+ }
297+ case kindScreencopy:
298+ if op <= 1 {
299+ c.capture(o, op, body)
300+ }
301+ case kindFrame:
302+ switch op {
303+ case 0:
304+ c.copy(o, id, o.buffers[word(body, 0)])
305+ case 1:
306+ c.send(1, 1, id)
307+ delete(o.kinds, id)
308+ delete(o.frames, id)
309+ }
310+ }
311+ return true
312+}
313+
314+func (c *compositor) advertise() {
315+ for i, spec := range c.cfg.outputs {
316+ c.sendGlobal(uint32(i+1), "wl_output", spec.version)
317+ }
318+ if c.cfg.duplicateGlobal {
319+ c.sendGlobal(1, "wl_output", c.cfg.outputs[0].version)
320+ }
321+ c.sendGlobal(globalShm, "wl_shm", 1)
322+ if !c.cfg.noScreencopy {
323+ c.sendGlobal(globalScreencopy, "zwlr_screencopy_manager_v1", c.cfg.screencopyVersion)
324+ }
325+}
326+
327+func (c *compositor) bind(o *objects, name uint32, iface string, version, id uint32) {
328+ switch iface {
329+ case "wl_output":
330+ o.kinds[id] = kindOutput
331+ idx := int(name) - 1
332+ o.outputs[id] = idx
333+ spec := c.cfg.outputs[idx]
334+ c.wmu.Lock()
335+ m := c.begin(id, 0)
336+ m = c.words(m, 0, 0, uint32(spec.width), uint32(spec.height), 0)
337+ m = c.str(m, "Test")
338+ m = c.str(m, "Model")
339+ m = c.words(m, 0)
340+ c.flush(m)
341+ c.wmu.Unlock()
342+ c.send(id, 1, 1, spec.width, spec.height, 60000)
343+ if version >= 2 {
344+ c.send(id, 3, uint32(spec.scale))
345+ }
346+ if version >= 4 {
347+ c.wmu.Lock()
348+ m := c.begin(id, 4)
349+ m = c.str(m, spec.name)
350+ c.flush(m)
351+ c.wmu.Unlock()
352+ }
353+ if version >= 2 {
354+ c.send(id, 2)
355+ }
356+ case "wl_shm":
357+ o.kinds[id] = kindShm
358+ c.send(id, 0, 0)
359+ c.send(id, 0, 1)
360+ case "zwlr_screencopy_manager_v1":
361+ o.kinds[id] = kindScreencopy
362+ default:
363+ c.t.Errorf("bind of unexpected interface %q", iface)
364+ }
365+}
366+
367+func (c *compositor) capture(o *objects, op uint16, body []byte) {
368+ frame := word(body, 0)
369+ out := o.outputs[word(body, 2)]
370+ spec := c.cfg.outputs[out]
371+ w, h := spec.width, spec.height
372+ c.mu.Lock()
373+ c.captures++
374+ if op == 1 {
375+ c.lastRegion = [4]int32{int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5)), int32(word(body, 6))}
376+ c.lastRegionSet = true
377+ w, h = word(body, 5), word(body, 6)
378+ }
379+ c.mu.Unlock()
380+ o.kinds[frame] = kindFrame
381+ o.frames[frame] = &frameState{output: out, w: w, h: h}
382+ if c.cfg.removeOnCapture {
383+ c.removeOutput(uint32(out + 1))
384+ c.send(frame, 3)
385+ return
386+ }
387+ if c.cfg.failCapture {
388+ c.send(frame, 3)
389+ return
390+ }
391+ if c.cfg.readyEarly {
392+ c.send(frame, 2, 0, 1, 500)
393+ return
394+ }
395+ list := c.cfg.announce
396+ if c.cfg.noShmOffer {
397+ c.send(frame, 6)
398+ return
399+ }
400+ if len(list) == 0 {
401+ list = []announce{{format: 1, width: w, height: h, stride: w * 4}}
402+ }
403+ for _, a := range list {
404+ c.send(frame, 0, a.format, a.width, a.height, a.stride)
405+ }
406+ if c.cfg.screencopyVersion >= 3 {
407+ c.send(frame, 6)
408+ }
409+}
410+
411+func (c *compositor) copy(o *objects, frame uint32, b *bufferInfo) {
412+ if b == nil {
413+ c.t.Error("copy with unknown buffer")
414+ return
415+ }
416+ c.mu.Lock()
417+ c.copies++
418+ if c.cfg.failCopies > 0 {
419+ c.cfg.failCopies--
420+ c.mu.Unlock()
421+ c.send(frame, 3)
422+ return
423+ }
424+ hold := c.cfg.hold
425+ if hold {
426+ c.pending.frame, c.pending.buffer, c.hasPending = frame, b, true
427+ }
428+ c.mu.Unlock()
429+ select {
430+ case c.copyEvent <- struct{}{}:
431+ default:
432+ }
433+ if !hold {
434+ c.finish(frame, b)
435+ }
436+}
437+
438+// completePending answers a copy that was held.
439+func (c *compositor) completePending() {
440+ c.mu.Lock()
441+ frame, b, ok := c.pending.frame, c.pending.buffer, c.hasPending
442+ c.hasPending = false
443+ c.mu.Unlock()
444+ if !ok {
445+ c.t.Error("no pending copy")
446+ return
447+ }
448+ c.finish(frame, b)
449+}
450+
451+// pixel is the deterministic content written for (x, y) of frame number seq.
452+func pixel(x, y int, seq uint32) [4]byte {
453+ return [4]byte{byte(x), byte(y), byte(seq), 0xff}
454+}
455+
456+func (c *compositor) finish(frame uint32, b *bufferInfo) {
457+ c.mu.Lock()
458+ c.seq++
459+ seq := c.seq
460+ c.mu.Unlock()
461+ data := b.pool.data[b.offset:]
462+ for y := 0; y < int(b.height); y++ {
463+ row := data[y*int(b.stride) : (y+1)*int(b.stride)]
464+ for x := 0; x < int(b.width); x++ {
465+ p := pixel(x, y, seq)
466+ copy(row[x*4:], p[:])
467+ }
468+ for i := int(b.width) * 4; i < len(row); i++ {
469+ row[i] = 0xEE // stride padding
470+ }
471+ }
472+ c.send(frame, 1, c.cfg.flags)
473+ c.send(frame, 2, 0, 1, 500)
474+}
475+
476+// removeOutput announces the removal of output global name (1-based).
477+func (c *compositor) removeOutput(name uint32) {
478+ c.wmu.Lock()
479+ m := c.begin(c.reg, 1)
480+ m = c.words(m, name)
481+ c.flush(m)
482+ c.wmu.Unlock()
483+}
484+
485+// Encoding helpers. Every message is built in c.out under wmu.
486+
487+func (c *compositor) send(obj uint32, op uint16, args ...uint32) {
488+ c.wmu.Lock()
489+ m := c.begin(obj, op)
490+ m = c.words(m, args...)
491+ c.flush(m)
492+ c.wmu.Unlock()
493+}
494+
495+func (c *compositor) sendGlobal(name uint32, iface string, version uint32) {
496+ c.wmu.Lock()
497+ m := c.begin(c.reg, 0)
498+ m = c.words(m, name)
499+ m = c.str(m, iface)
500+ m = c.words(m, version)
501+ c.flush(m)
502+ c.wmu.Unlock()
503+}
504+
505+func (c *compositor) begin(obj uint32, op uint16) []byte {
506+ m := c.out[:8]
507+ binary.LittleEndian.PutUint32(m, obj)
508+ binary.LittleEndian.PutUint32(m[4:], uint32(op))
509+ return m
510+}
511+
512+func (c *compositor) words(m []byte, args ...uint32) []byte {
513+ for _, a := range args {
514+ m = binary.LittleEndian.AppendUint32(m, a)
515+ }
516+ return m
517+}
518+
519+func (c *compositor) str(m []byte, s string) []byte {
520+ m = binary.LittleEndian.AppendUint32(m, uint32(len(s)+1))
521+ m = append(m, s...)
522+ m = append(m, 0)
523+ return append(m, bytes.Repeat([]byte{0}, (4-(len(s)+1)%4)%4)...)
524+}
525+
526+func (c *compositor) flush(m []byte) {
527+ binary.LittleEndian.PutUint32(m[4:], uint32(len(m))<<16|binary.LittleEndian.Uint32(m[4:])&0xffff)
528+ c.mu.Lock()
529+ conn := c.conn
530+ c.mu.Unlock()
531+ if _, err := conn.Write(m); err != nil {
532+ // The client may have gone away during teardown.
533+ return
534+ }
535+}
@@ -0,0 +1,535 @@
1+package wayland
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "net"
7+ "path/filepath"
8+ "sync"
9+ "syscall"
10+ "testing"
11+
12+ "golang.org/x/sys/unix"
13+)
14+
15+// This file implements the compositor side of the Wayland wire protocol over a
16+// real Unix socket: it decodes the requests the Source sends (including
17+// SCM_RIGHTS descriptors), maps the client's shm pools and answers with
18+// encoded events. It is a protocol peer, not a substitute for a project type.
19+
20+const (
21+ kindDisplay = iota + 1
22+ kindRegistry
23+ kindOutput
24+ kindShm
25+ kindPool
26+ kindBuffer
27+ kindScreencopy
28+ kindFrame
29+)
30+
31+const (
32+ globalShm = 1000
33+ globalScreencopy = 1001
34+)
35+
36+type outputSpec struct {
37+ name string
38+ width, height uint32
39+ scale int32
40+ version uint32
41+}
42+
43+// announce is one zwlr_screencopy_frame_v1.buffer event.
44+type announce struct{ format, width, height, stride uint32 }
45+
46+type compositorConfig struct {
47+ outputs []outputSpec
48+ screencopyVersion uint32 // 0 selects 3
49+ noScreencopy bool
50+ announce []announce // empty: derived from the output or region
51+ flags uint32
52+ hold bool // do not answer copy until completePending
53+ failCapture bool
54+ removeOnCapture bool // global_remove of the output, then failed
55+ silent bool // accept the connection and never answer
56+ failCopies int // answer this many copy requests with failed
57+ readyEarly bool // send ready right after the buffer events, before any copy
58+ noShmOffer bool // send only buffer_done: no wl_shm buffer is offered
59+ duplicateGlobal bool // announce the first output global twice
60+}
61+
62+type poolMap struct{ data []byte }
63+
64+type bufferInfo struct {
65+ pool *poolMap
66+ offset int
67+ width, height, stride, format uint32
68+}
69+
70+type compositor struct {
71+ t testing.TB
72+ path string
73+ ln net.Listener
74+ cfg compositorConfig
75+
76+ mu sync.Mutex
77+ conn *net.UnixConn
78+ pools int
79+ buffers int
80+ destroyedBuffers int
81+ captures int
82+ copies int
83+ fdsReceived int
84+ lastRegion [4]int32
85+ lastRegionSet bool
86+ seq uint32
87+ pending struct {
88+ frame uint32
89+ buffer *bufferInfo
90+ }
91+ hasPending bool
92+ copyEvent chan struct{}
93+ done chan struct{}
94+
95+ wmu sync.Mutex
96+ out [4096]byte
97+ reg uint32
98+}
99+
100+func startCompositor(t testing.TB, cfg compositorConfig) *compositor {
101+ t.Helper()
102+ if len(cfg.outputs) == 0 {
103+ cfg.outputs = []outputSpec{{name: "TEST-1", width: 8, height: 4, scale: 1, version: 4}}
104+ }
105+ if cfg.screencopyVersion == 0 {
106+ cfg.screencopyVersion = 3
107+ }
108+ dir, err := filepath.EvalSymlinks(t.TempDir())
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ ln, err := net.Listen("unix", filepath.Join(dir, "wl-test"))
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ c := &compositor{t: t, path: ln.Addr().String(), ln: ln, cfg: cfg, copyEvent: make(chan struct{}, 64), done: make(chan struct{})}
117+ go c.serve()
118+ t.Cleanup(func() {
119+ ln.Close()
120+ c.mu.Lock()
121+ if c.conn != nil {
122+ c.conn.Close()
123+ }
124+ c.mu.Unlock()
125+ <-c.done
126+ })
127+ return c
128+}
129+
130+func (c *compositor) stat(f func(*compositor) int) int {
131+ c.mu.Lock()
132+ defer c.mu.Unlock()
133+ return f(c)
134+}
135+
136+func (c *compositor) serve() {
137+ defer close(c.done)
138+ nc, err := c.ln.Accept()
139+ if err != nil {
140+ return
141+ }
142+ conn := nc.(*net.UnixConn)
143+ c.mu.Lock()
144+ c.conn = conn
145+ c.mu.Unlock()
146+ defer conn.Close()
147+ if c.cfg.silent {
148+ buf := make([]byte, 64)
149+ for {
150+ if _, err := conn.Read(buf); err != nil {
151+ return
152+ }
153+ }
154+ }
155+ c.loop(conn)
156+}
157+
158+type objects struct {
159+ kinds map[uint32]int
160+ outputs map[uint32]int
161+ pools map[uint32]*poolMap
162+ buffers map[uint32]*bufferInfo
163+ frames map[uint32]*frameState
164+}
165+
166+type frameState struct {
167+ output int
168+ w, h uint32
169+}
170+
171+func (c *compositor) loop(conn *net.UnixConn) {
172+ objs := &objects{
173+ kinds: map[uint32]int{1: kindDisplay}, outputs: map[uint32]int{}, pools: map[uint32]*poolMap{},
174+ buffers: map[uint32]*bufferInfo{}, frames: map[uint32]*frameState{},
175+ }
176+ buf := make([]byte, 0, 1<<16)
177+ chunk := make([]byte, 1<<15)
178+ oob := make([]byte, unix.CmsgSpace(4*16))
179+ var fds []int
180+ defer func() {
181+ for _, fd := range fds {
182+ unix.Close(fd)
183+ }
184+ }()
185+ for {
186+ n, oobn, _, _, err := conn.ReadMsgUnix(chunk, oob)
187+ if oobn > 0 {
188+ msgs, perr := syscall.ParseSocketControlMessage(oob[:oobn])
189+ if perr != nil {
190+ c.t.Errorf("control message: %v", perr)
191+ return
192+ }
193+ for i := range msgs {
194+ got, perr := syscall.ParseUnixRights(&msgs[i])
195+ if perr != nil {
196+ c.t.Errorf("rights: %v", perr)
197+ return
198+ }
199+ fds = append(fds, got...)
200+ c.mu.Lock()
201+ c.fdsReceived += len(got)
202+ c.mu.Unlock()
203+ }
204+ }
205+ buf = append(buf, chunk[:max(n, 0)]...)
206+ for len(buf) >= 8 {
207+ size := int(binary.LittleEndian.Uint32(buf[4:]) >> 16)
208+ if size < 8 || len(buf) < size {
209+ break
210+ }
211+ id := binary.LittleEndian.Uint32(buf)
212+ op := uint16(binary.LittleEndian.Uint32(buf[4:]))
213+ if !c.handle(objs, id, op, buf[8:size], &fds) {
214+ return
215+ }
216+ buf = buf[:copy(buf, buf[size:])]
217+ }
218+ if err != nil {
219+ return
220+ }
221+ }
222+}
223+
224+func word(b []byte, i int) uint32 { return binary.LittleEndian.Uint32(b[i*4:]) }
225+
226+func (c *compositor) handle(o *objects, id uint32, op uint16, body []byte, fds *[]int) bool {
227+ switch o.kinds[id] {
228+ case kindDisplay:
229+ switch op {
230+ case 0: // sync
231+ cb := word(body, 0)
232+ c.send(cb, 0, 0)
233+ c.send(1, 1, cb)
234+ case 1: // get_registry
235+ c.wmu.Lock()
236+ c.reg = word(body, 0)
237+ c.wmu.Unlock()
238+ o.kinds[word(body, 0)] = kindRegistry
239+ c.advertise()
240+ }
241+ case kindRegistry:
242+ name := word(body, 0)
243+ n := int(word(body, 1))
244+ iface := string(body[8 : 8+n-1])
245+ i := 8 + (n+3)&^3
246+ version, newID := word(body, i/4), word(body, i/4+1)
247+ c.bind(o, name, iface, version, newID)
248+ case kindOutput:
249+ if op == 0 {
250+ c.send(1, 1, id)
251+ delete(o.kinds, id)
252+ }
253+ case kindShm:
254+ if op == 0 {
255+ if len(*fds) == 0 {
256+ c.t.Error("create_pool without descriptor")
257+ return false
258+ }
259+ fd := (*fds)[0]
260+ *fds = (*fds)[1:]
261+ size := int(word(body, 1))
262+ data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
263+ unix.Close(fd)
264+ if err != nil {
265+ c.t.Errorf("server mmap: %v", err)
266+ return false
267+ }
268+ pid := word(body, 0)
269+ o.kinds[pid] = kindPool
270+ o.pools[pid] = &poolMap{data: data}
271+ c.mu.Lock()
272+ c.pools++
273+ c.mu.Unlock()
274+ }
275+ case kindPool:
276+ switch op {
277+ case 0:
278+ bid := word(body, 0)
279+ o.kinds[bid] = kindBuffer
280+ o.buffers[bid] = &bufferInfo{pool: o.pools[id], offset: int(word(body, 1)), width: word(body, 2), height: word(body, 3), stride: word(body, 4), format: word(body, 5)}
281+ c.mu.Lock()
282+ c.buffers++
283+ c.mu.Unlock()
284+ case 1:
285+ c.send(1, 1, id)
286+ delete(o.kinds, id)
287+ }
288+ case kindBuffer:
289+ if op == 0 {
290+ c.mu.Lock()
291+ c.destroyedBuffers++
292+ c.mu.Unlock()
293+ c.send(1, 1, id)
294+ delete(o.kinds, id)
295+ delete(o.buffers, id)
296+ }
297+ case kindScreencopy:
298+ if op <= 1 {
299+ c.capture(o, op, body)
300+ }
301+ case kindFrame:
302+ switch op {
303+ case 0:
304+ c.copy(o, id, o.buffers[word(body, 0)])
305+ case 1:
306+ c.send(1, 1, id)
307+ delete(o.kinds, id)
308+ delete(o.frames, id)
309+ }
310+ }
311+ return true
312+}
313+
314+func (c *compositor) advertise() {
315+ for i, spec := range c.cfg.outputs {
316+ c.sendGlobal(uint32(i+1), "wl_output", spec.version)
317+ }
318+ if c.cfg.duplicateGlobal {
319+ c.sendGlobal(1, "wl_output", c.cfg.outputs[0].version)
320+ }
321+ c.sendGlobal(globalShm, "wl_shm", 1)
322+ if !c.cfg.noScreencopy {
323+ c.sendGlobal(globalScreencopy, "zwlr_screencopy_manager_v1", c.cfg.screencopyVersion)
324+ }
325+}
326+
327+func (c *compositor) bind(o *objects, name uint32, iface string, version, id uint32) {
328+ switch iface {
329+ case "wl_output":
330+ o.kinds[id] = kindOutput
331+ idx := int(name) - 1
332+ o.outputs[id] = idx
333+ spec := c.cfg.outputs[idx]
334+ c.wmu.Lock()
335+ m := c.begin(id, 0)
336+ m = c.words(m, 0, 0, uint32(spec.width), uint32(spec.height), 0)
337+ m = c.str(m, "Test")
338+ m = c.str(m, "Model")
339+ m = c.words(m, 0)
340+ c.flush(m)
341+ c.wmu.Unlock()
342+ c.send(id, 1, 1, spec.width, spec.height, 60000)
343+ if version >= 2 {
344+ c.send(id, 3, uint32(spec.scale))
345+ }
346+ if version >= 4 {
347+ c.wmu.Lock()
348+ m := c.begin(id, 4)
349+ m = c.str(m, spec.name)
350+ c.flush(m)
351+ c.wmu.Unlock()
352+ }
353+ if version >= 2 {
354+ c.send(id, 2)
355+ }
356+ case "wl_shm":
357+ o.kinds[id] = kindShm
358+ c.send(id, 0, 0)
359+ c.send(id, 0, 1)
360+ case "zwlr_screencopy_manager_v1":
361+ o.kinds[id] = kindScreencopy
362+ default:
363+ c.t.Errorf("bind of unexpected interface %q", iface)
364+ }
365+}
366+
367+func (c *compositor) capture(o *objects, op uint16, body []byte) {
368+ frame := word(body, 0)
369+ out := o.outputs[word(body, 2)]
370+ spec := c.cfg.outputs[out]
371+ w, h := spec.width, spec.height
372+ c.mu.Lock()
373+ c.captures++
374+ if op == 1 {
375+ c.lastRegion = [4]int32{int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5)), int32(word(body, 6))}
376+ c.lastRegionSet = true
377+ w, h = word(body, 5), word(body, 6)
378+ }
379+ c.mu.Unlock()
380+ o.kinds[frame] = kindFrame
381+ o.frames[frame] = &frameState{output: out, w: w, h: h}
382+ if c.cfg.removeOnCapture {
383+ c.removeOutput(uint32(out + 1))
384+ c.send(frame, 3)
385+ return
386+ }
387+ if c.cfg.failCapture {
388+ c.send(frame, 3)
389+ return
390+ }
391+ if c.cfg.readyEarly {
392+ c.send(frame, 2, 0, 1, 500)
393+ return
394+ }
395+ list := c.cfg.announce
396+ if c.cfg.noShmOffer {
397+ c.send(frame, 6)
398+ return
399+ }
400+ if len(list) == 0 {
401+ list = []announce{{format: 1, width: w, height: h, stride: w * 4}}
402+ }
403+ for _, a := range list {
404+ c.send(frame, 0, a.format, a.width, a.height, a.stride)
405+ }
406+ if c.cfg.screencopyVersion >= 3 {
407+ c.send(frame, 6)
408+ }
409+}
410+
411+func (c *compositor) copy(o *objects, frame uint32, b *bufferInfo) {
412+ if b == nil {
413+ c.t.Error("copy with unknown buffer")
414+ return
415+ }
416+ c.mu.Lock()
417+ c.copies++
418+ if c.cfg.failCopies > 0 {
419+ c.cfg.failCopies--
420+ c.mu.Unlock()
421+ c.send(frame, 3)
422+ return
423+ }
424+ hold := c.cfg.hold
425+ if hold {
426+ c.pending.frame, c.pending.buffer, c.hasPending = frame, b, true
427+ }
428+ c.mu.Unlock()
429+ select {
430+ case c.copyEvent <- struct{}{}:
431+ default:
432+ }
433+ if !hold {
434+ c.finish(frame, b)
435+ }
436+}
437+
438+// completePending answers a copy that was held.
439+func (c *compositor) completePending() {
440+ c.mu.Lock()
441+ frame, b, ok := c.pending.frame, c.pending.buffer, c.hasPending
442+ c.hasPending = false
443+ c.mu.Unlock()
444+ if !ok {
445+ c.t.Error("no pending copy")
446+ return
447+ }
448+ c.finish(frame, b)
449+}
450+
451+// pixel is the deterministic content written for (x, y) of frame number seq.
452+func pixel(x, y int, seq uint32) [4]byte {
453+ return [4]byte{byte(x), byte(y), byte(seq), 0xff}
454+}
455+
456+func (c *compositor) finish(frame uint32, b *bufferInfo) {
457+ c.mu.Lock()
458+ c.seq++
459+ seq := c.seq
460+ c.mu.Unlock()
461+ data := b.pool.data[b.offset:]
462+ for y := 0; y < int(b.height); y++ {
463+ row := data[y*int(b.stride) : (y+1)*int(b.stride)]
464+ for x := 0; x < int(b.width); x++ {
465+ p := pixel(x, y, seq)
466+ copy(row[x*4:], p[:])
467+ }
468+ for i := int(b.width) * 4; i < len(row); i++ {
469+ row[i] = 0xEE // stride padding
470+ }
471+ }
472+ c.send(frame, 1, c.cfg.flags)
473+ c.send(frame, 2, 0, 1, 500)
474+}
475+
476+// removeOutput announces the removal of output global name (1-based).
477+func (c *compositor) removeOutput(name uint32) {
478+ c.wmu.Lock()
479+ m := c.begin(c.reg, 1)
480+ m = c.words(m, name)
481+ c.flush(m)
482+ c.wmu.Unlock()
483+}
484+
485+// Encoding helpers. Every message is built in c.out under wmu.
486+
487+func (c *compositor) send(obj uint32, op uint16, args ...uint32) {
488+ c.wmu.Lock()
489+ m := c.begin(obj, op)
490+ m = c.words(m, args...)
491+ c.flush(m)
492+ c.wmu.Unlock()
493+}
494+
495+func (c *compositor) sendGlobal(name uint32, iface string, version uint32) {
496+ c.wmu.Lock()
497+ m := c.begin(c.reg, 0)
498+ m = c.words(m, name)
499+ m = c.str(m, iface)
500+ m = c.words(m, version)
501+ c.flush(m)
502+ c.wmu.Unlock()
503+}
504+
505+func (c *compositor) begin(obj uint32, op uint16) []byte {
506+ m := c.out[:8]
507+ binary.LittleEndian.PutUint32(m, obj)
508+ binary.LittleEndian.PutUint32(m[4:], uint32(op))
509+ return m
510+}
511+
512+func (c *compositor) words(m []byte, args ...uint32) []byte {
513+ for _, a := range args {
514+ m = binary.LittleEndian.AppendUint32(m, a)
515+ }
516+ return m
517+}
518+
519+func (c *compositor) str(m []byte, s string) []byte {
520+ m = binary.LittleEndian.AppendUint32(m, uint32(len(s)+1))
521+ m = append(m, s...)
522+ m = append(m, 0)
523+ return append(m, bytes.Repeat([]byte{0}, (4-(len(s)+1)%4)%4)...)
524+}
525+
526+func (c *compositor) flush(m []byte) {
527+ binary.LittleEndian.PutUint32(m[4:], uint32(len(m))<<16|binary.LittleEndian.Uint32(m[4:])&0xffff)
528+ c.mu.Lock()
529+ conn := c.conn
530+ c.mu.Unlock()
531+ if _, err := conn.Write(m); err != nil {
532+ // The client may have gone away during teardown.
533+ return
534+ }
535+}
A internal/adapters/wayland/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package wayland
4+
5+const raceEnabled = false
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package wayland
4+
5+const raceEnabled = false
A internal/adapters/wayland/race_test.go +6 -0
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package wayland
4+
5+// The race detector adds allocations, so allocation budgets do not apply.
6+const raceEnabled = true
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package wayland
4+
5+// The race detector adds allocations, so allocation budgets do not apply.
6+const raceEnabled = true
A internal/adapters/wayland/screencopy.go +199 -0
@@ -0,0 +1,199 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/wlturbo"
8+
9+ "github.com/bnema/nefercap/internal/ports"
10+)
11+
12+// wl_shm format values for the two supported native little-endian layouts.
13+const (
14+ shmARGB8888 = uint32(ports.ARGB8888)
15+ shmXRGB8888 = uint32(ports.XRGB8888)
16+)
17+
18+const flagYInvert = 1 // zwlr_screencopy_frame_v1.flags.y_invert
19+
20+// The generated bindings of wlturbo v0.3 do not cover wlr-screencopy, so the
21+// two objects used are declared here with their wire signatures.
22+
23+type screencopyManager struct{ wlturbo.BaseProxy }
24+
25+func (*screencopyManager) EventSignature(uint16) (string, bool) { return "", false }
26+
27+const (
28+ reqCaptureOutput = 0
29+ reqCaptureOutputRegion = 1
30+ reqFrameCopy = 0
31+ reqFrameDestroy = 1
32+)
33+
34+// frameProxy is one zwlr_screencopy_frame_v1. Its events are dispatched on the
35+// goroutine that owns the operation.
36+type frameProxy struct {
37+ wlturbo.BaseProxy
38+ out *output
39+
40+ haveBuffer bool // a buffer event announcing a supported shm format arrived
41+ sawBuffer bool
42+ unsupported uint32 // last rejected format
43+ bufDone bool
44+ geom bufGeom
45+
46+ copied bool
47+ yInvert bool
48+ ready bool
49+ failed bool
50+}
51+
52+func (*frameProxy) EventSignature(op uint16) (string, bool) {
53+ switch op {
54+ case 0, 4:
55+ return "uint,uint,uint,uint,", true // buffer, damage
56+ case 1:
57+ return "uint,", true // flags
58+ case 2, 5:
59+ return "uint,uint,uint,", true // ready, linux_dmabuf
60+ case 3, 6:
61+ return "", true // failed, buffer_done
62+ }
63+ return "", false
64+}
65+
66+func (f *frameProxy) Dispatch(e *wlturbo.Event) {
67+ switch e.Opcode {
68+ case 0:
69+ format, w, h, stride := e.Uint32(), e.Uint32(), e.Uint32(), e.Uint32()
70+ f.sawBuffer = true
71+ switch {
72+ case format != shmARGB8888 && format != shmXRGB8888:
73+ f.unsupported = format
74+ case !f.haveBuffer:
75+ f.haveBuffer = true
76+ f.geom = bufGeom{format: format, width: w, height: h, stride: stride}
77+ }
78+ case 1:
79+ f.yInvert = e.Uint32()&flagYInvert != 0
80+ case 2:
81+ f.ready = true
82+ case 3:
83+ f.failed = true
84+ case 6:
85+ f.bufDone = true
86+ }
87+}
88+
89+func validRegion(r ports.Region) error {
90+ if r == (ports.Region{}) {
91+ return nil
92+ }
93+ if r.X < 0 || r.Y < 0 || r.Width < 1 || r.Height < 1 ||
94+ r.X > ports.MaxDimension || r.Y > ports.MaxDimension ||
95+ r.Width > ports.MaxDimension || r.Height > ports.MaxDimension {
96+ return fmt.Errorf("%w: %+v", ports.ErrInvalidRegion, r)
97+ }
98+ return nil
99+}
100+
101+func (s *Source) capture(t ports.Target) (ports.Frame, error) {
102+ out := s.outputs[t.OutputID]
103+ if out == nil {
104+ return ports.Frame{}, ports.ErrOutputNotFound
105+ }
106+ f := &frameProxy{out: out}
107+ f.SetContext(s.wl)
108+ var err error
109+ if t.Region == (ports.Region{}) {
110+ err = s.wl.Request(wlturbo.Request{Proxy: s.screencopy, Opcode: reqCaptureOutput, Name: "zwlr_screencopy_manager_v1.capture_output", Child: f},
111+ f, int32(0), out.proxy)
112+ } else {
113+ r := t.Region
114+ err = s.wl.Request(wlturbo.Request{Proxy: s.screencopy, Opcode: reqCaptureOutputRegion, Name: "zwlr_screencopy_manager_v1.capture_output_region", Child: f},
115+ f, int32(0), out.proxy, int32(r.X), int32(r.Y), int32(r.Width), int32(r.Height))
116+ }
117+ if err != nil {
118+ s.terminate()
119+ return ports.Frame{}, fmt.Errorf("wayland: capture request: %w", err)
120+ }
121+ // Until copy is sent the compositor does not touch our buffer, so a
122+ // negotiation failure leaves the Source usable. After copy, any failure
123+ // other than the compositor's own "failed" event ends in terminal state.
124+ for {
125+ if err := s.dispatch(); err != nil {
126+ return ports.Frame{}, err
127+ }
128+ switch {
129+ case f.failed:
130+ s.destroyFrame(f)
131+ // The removal may trail the failure on the wire: read it.
132+ if err := s.roundtrip(); err != nil {
133+ return ports.Frame{}, err
134+ }
135+ if f.out.removed {
136+ return ports.Frame{}, ports.ErrOutputNotFound
137+ }
138+ return ports.Frame{}, errors.New("wayland: compositor failed the capture")
139+ case f.ready && !f.copied:
140+ s.terminate()
141+ return ports.Frame{}, errors.New("wayland: compositor completed a frame that was never copied")
142+ case f.ready:
143+ s.destroyFrame(f)
144+ return s.frameOf(f)
145+ case !f.copied && s.negotiated(f):
146+ if err := s.startCopy(f); err != nil {
147+ s.destroyFrame(f)
148+ return ports.Frame{}, err
149+ }
150+ }
151+ }
152+}
153+
154+// negotiated reports whether the compositor announced every buffer type.
155+// Before version 3 there is no buffer_done: the single buffer event is final.
156+func (s *Source) negotiated(f *frameProxy) bool {
157+ if s.screencopy.Version() >= 3 {
158+ return f.bufDone
159+ }
160+ return f.sawBuffer
161+}
162+
163+func (s *Source) startCopy(f *frameProxy) error {
164+ if !f.sawBuffer {
165+ return fmt.Errorf("%w: compositor offers no wl_shm buffer", ports.ErrUnsupportedFormat)
166+ }
167+ if !f.haveBuffer {
168+ return fmt.Errorf("%w: compositor offers shm format %#x", ports.ErrUnsupportedFormat, f.unsupported)
169+ }
170+ if err := f.geom.validate(); err != nil {
171+ return err
172+ }
173+ if err := s.ensureBuffer(f.geom); err != nil {
174+ return err
175+ }
176+ f.copied = true
177+ if err := s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameCopy, Name: "zwlr_screencopy_frame_v1.copy"}, s.buf.buffer); err != nil {
178+ s.terminate()
179+ return fmt.Errorf("wayland: copy request: %w", err)
180+ }
181+ return nil
182+}
183+
184+func (s *Source) destroyFrame(f *frameProxy) {
185+ _ = s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameDestroy, Name: "zwlr_screencopy_frame_v1.destroy", Destructor: true})
186+}
187+
188+func (s *Source) frameOf(f *frameProxy) (ports.Frame, error) {
189+ g := f.geom
190+ frame := ports.Frame{
191+ Pixels: s.buf.data[:g.size:g.size],
192+ Width: int(g.width), Height: int(g.height), Stride: int(g.stride),
193+ Format: ports.PixelFormat(g.format), YInvert: f.yInvert,
194+ }
195+ if err := frame.Validate(); err != nil {
196+ return ports.Frame{}, err
197+ }
198+ return frame, nil
199+}
@@ -0,0 +1,199 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/wlturbo"
8+
9+ "github.com/bnema/nefercap/internal/ports"
10+)
11+
12+// wl_shm format values for the two supported native little-endian layouts.
13+const (
14+ shmARGB8888 = uint32(ports.ARGB8888)
15+ shmXRGB8888 = uint32(ports.XRGB8888)
16+)
17+
18+const flagYInvert = 1 // zwlr_screencopy_frame_v1.flags.y_invert
19+
20+// The generated bindings of wlturbo v0.3 do not cover wlr-screencopy, so the
21+// two objects used are declared here with their wire signatures.
22+
23+type screencopyManager struct{ wlturbo.BaseProxy }
24+
25+func (*screencopyManager) EventSignature(uint16) (string, bool) { return "", false }
26+
27+const (
28+ reqCaptureOutput = 0
29+ reqCaptureOutputRegion = 1
30+ reqFrameCopy = 0
31+ reqFrameDestroy = 1
32+)
33+
34+// frameProxy is one zwlr_screencopy_frame_v1. Its events are dispatched on the
35+// goroutine that owns the operation.
36+type frameProxy struct {
37+ wlturbo.BaseProxy
38+ out *output
39+
40+ haveBuffer bool // a buffer event announcing a supported shm format arrived
41+ sawBuffer bool
42+ unsupported uint32 // last rejected format
43+ bufDone bool
44+ geom bufGeom
45+
46+ copied bool
47+ yInvert bool
48+ ready bool
49+ failed bool
50+}
51+
52+func (*frameProxy) EventSignature(op uint16) (string, bool) {
53+ switch op {
54+ case 0, 4:
55+ return "uint,uint,uint,uint,", true // buffer, damage
56+ case 1:
57+ return "uint,", true // flags
58+ case 2, 5:
59+ return "uint,uint,uint,", true // ready, linux_dmabuf
60+ case 3, 6:
61+ return "", true // failed, buffer_done
62+ }
63+ return "", false
64+}
65+
66+func (f *frameProxy) Dispatch(e *wlturbo.Event) {
67+ switch e.Opcode {
68+ case 0:
69+ format, w, h, stride := e.Uint32(), e.Uint32(), e.Uint32(), e.Uint32()
70+ f.sawBuffer = true
71+ switch {
72+ case format != shmARGB8888 && format != shmXRGB8888:
73+ f.unsupported = format
74+ case !f.haveBuffer:
75+ f.haveBuffer = true
76+ f.geom = bufGeom{format: format, width: w, height: h, stride: stride}
77+ }
78+ case 1:
79+ f.yInvert = e.Uint32()&flagYInvert != 0
80+ case 2:
81+ f.ready = true
82+ case 3:
83+ f.failed = true
84+ case 6:
85+ f.bufDone = true
86+ }
87+}
88+
89+func validRegion(r ports.Region) error {
90+ if r == (ports.Region{}) {
91+ return nil
92+ }
93+ if r.X < 0 || r.Y < 0 || r.Width < 1 || r.Height < 1 ||
94+ r.X > ports.MaxDimension || r.Y > ports.MaxDimension ||
95+ r.Width > ports.MaxDimension || r.Height > ports.MaxDimension {
96+ return fmt.Errorf("%w: %+v", ports.ErrInvalidRegion, r)
97+ }
98+ return nil
99+}
100+
101+func (s *Source) capture(t ports.Target) (ports.Frame, error) {
102+ out := s.outputs[t.OutputID]
103+ if out == nil {
104+ return ports.Frame{}, ports.ErrOutputNotFound
105+ }
106+ f := &frameProxy{out: out}
107+ f.SetContext(s.wl)
108+ var err error
109+ if t.Region == (ports.Region{}) {
110+ err = s.wl.Request(wlturbo.Request{Proxy: s.screencopy, Opcode: reqCaptureOutput, Name: "zwlr_screencopy_manager_v1.capture_output", Child: f},
111+ f, int32(0), out.proxy)
112+ } else {
113+ r := t.Region
114+ err = s.wl.Request(wlturbo.Request{Proxy: s.screencopy, Opcode: reqCaptureOutputRegion, Name: "zwlr_screencopy_manager_v1.capture_output_region", Child: f},
115+ f, int32(0), out.proxy, int32(r.X), int32(r.Y), int32(r.Width), int32(r.Height))
116+ }
117+ if err != nil {
118+ s.terminate()
119+ return ports.Frame{}, fmt.Errorf("wayland: capture request: %w", err)
120+ }
121+ // Until copy is sent the compositor does not touch our buffer, so a
122+ // negotiation failure leaves the Source usable. After copy, any failure
123+ // other than the compositor's own "failed" event ends in terminal state.
124+ for {
125+ if err := s.dispatch(); err != nil {
126+ return ports.Frame{}, err
127+ }
128+ switch {
129+ case f.failed:
130+ s.destroyFrame(f)
131+ // The removal may trail the failure on the wire: read it.
132+ if err := s.roundtrip(); err != nil {
133+ return ports.Frame{}, err
134+ }
135+ if f.out.removed {
136+ return ports.Frame{}, ports.ErrOutputNotFound
137+ }
138+ return ports.Frame{}, errors.New("wayland: compositor failed the capture")
139+ case f.ready && !f.copied:
140+ s.terminate()
141+ return ports.Frame{}, errors.New("wayland: compositor completed a frame that was never copied")
142+ case f.ready:
143+ s.destroyFrame(f)
144+ return s.frameOf(f)
145+ case !f.copied && s.negotiated(f):
146+ if err := s.startCopy(f); err != nil {
147+ s.destroyFrame(f)
148+ return ports.Frame{}, err
149+ }
150+ }
151+ }
152+}
153+
154+// negotiated reports whether the compositor announced every buffer type.
155+// Before version 3 there is no buffer_done: the single buffer event is final.
156+func (s *Source) negotiated(f *frameProxy) bool {
157+ if s.screencopy.Version() >= 3 {
158+ return f.bufDone
159+ }
160+ return f.sawBuffer
161+}
162+
163+func (s *Source) startCopy(f *frameProxy) error {
164+ if !f.sawBuffer {
165+ return fmt.Errorf("%w: compositor offers no wl_shm buffer", ports.ErrUnsupportedFormat)
166+ }
167+ if !f.haveBuffer {
168+ return fmt.Errorf("%w: compositor offers shm format %#x", ports.ErrUnsupportedFormat, f.unsupported)
169+ }
170+ if err := f.geom.validate(); err != nil {
171+ return err
172+ }
173+ if err := s.ensureBuffer(f.geom); err != nil {
174+ return err
175+ }
176+ f.copied = true
177+ if err := s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameCopy, Name: "zwlr_screencopy_frame_v1.copy"}, s.buf.buffer); err != nil {
178+ s.terminate()
179+ return fmt.Errorf("wayland: copy request: %w", err)
180+ }
181+ return nil
182+}
183+
184+func (s *Source) destroyFrame(f *frameProxy) {
185+ _ = s.wl.Request(wlturbo.Request{Proxy: f, Opcode: reqFrameDestroy, Name: "zwlr_screencopy_frame_v1.destroy", Destructor: true})
186+}
187+
188+func (s *Source) frameOf(f *frameProxy) (ports.Frame, error) {
189+ g := f.geom
190+ frame := ports.Frame{
191+ Pixels: s.buf.data[:g.size:g.size],
192+ Width: int(g.width), Height: int(g.height), Stride: int(g.stride),
193+ Format: ports.PixelFormat(g.format), YInvert: f.yInvert,
194+ }
195+ if err := frame.Validate(); err != nil {
196+ return ports.Frame{}, err
197+ }
198+ return frame, nil
199+}
A internal/adapters/wayland/shm.go +94 -0
@@ -0,0 +1,94 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/wlturbo"
8+ "github.com/bnema/wlturbo/protocol/core"
9+ "golang.org/x/sys/unix"
10+
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+// bufGeom is the buffer layout announced by the compositor. It is reused only
15+// while the compositor keeps announcing the same one.
16+type bufGeom struct {
17+ format, width, height, stride uint32
18+ size int
19+}
20+
21+// validate bounds every dimension before any memory is created or mapped.
22+func (g *bufGeom) validate() error {
23+ if g.width < 1 || g.height < 1 || g.width > ports.MaxDimension || g.height > ports.MaxDimension {
24+ return fmt.Errorf("wayland: invalid buffer dimensions %dx%d", g.width, g.height)
25+ }
26+ if g.stride < g.width*ports.BytesPerPixel || g.stride%ports.BytesPerPixel != 0 || g.stride > ports.MaxFrameBytes/g.height {
27+ return fmt.Errorf("wayland: invalid buffer stride %d for %dx%d", g.stride, g.width, g.height)
28+ }
29+ g.size = int(g.stride) * int(g.height)
30+ return nil
31+}
32+
33+// shmBuffer is the single wl_shm mapping and wl_buffer reused between
34+// captures. The pool proxy is destroyed right after buffer creation, which the
35+// protocol allows: the buffer keeps the compositor-side mapping alive.
36+type shmBuffer struct {
37+ geom bufGeom
38+ data []byte
39+ buffer *core.Buffer
40+}
41+
42+// ensureBuffer makes s.buf match g, reusing it when unchanged.
43+func (s *Source) ensureBuffer(g bufGeom) error {
44+ if s.buf.buffer != nil && s.buf.geom == g {
45+ return nil
46+ }
47+ if err := s.releaseBuffer(true); err != nil {
48+ s.terminate()
49+ return err
50+ }
51+ fd, err := wlturbo.CreateAnonymousFile(int64(g.size))
52+ if err != nil {
53+ return fmt.Errorf("wayland: create shm file: %w", err)
54+ }
55+ data, err := wlturbo.MapMemory(fd, g.size)
56+ if err != nil {
57+ _ = unix.Close(fd)
58+ return fmt.Errorf("wayland: map shm file: %w", err)
59+ }
60+ // Context.Request closes fd once the message is sent, so on success the
61+ // descriptor belongs to the transport (the mapping outlives it). On error
62+ // the caller still owns it.
63+ pool, err := s.shm.CreatePool(fd, int32(g.size))
64+ if err != nil {
65+ _ = unix.Close(fd)
66+ _ = wlturbo.UnmapMemory(data)
67+ s.terminate()
68+ return fmt.Errorf("wayland: create pool: %w", err)
69+ }
70+ buffer, err := pool.CreateBuffer(0, int32(g.width), int32(g.height), int32(g.stride), g.format)
71+ _ = pool.Destroy()
72+ if err != nil {
73+ _ = wlturbo.UnmapMemory(data)
74+ s.terminate()
75+ return fmt.Errorf("wayland: create buffer: %w", err)
76+ }
77+ s.buf = shmBuffer{geom: g, data: data, buffer: buffer}
78+ return nil
79+}
80+
81+// releaseBuffer unmaps the storage. With destroy set, and while connected, it
82+// also destroys the wl_buffer. The caller holds opMu.
83+func (s *Source) releaseBuffer(destroy bool) error {
84+ b := s.buf
85+ s.buf = shmBuffer{}
86+ var err error
87+ if b.buffer != nil && destroy && !s.dead.Load() {
88+ err = b.buffer.Destroy()
89+ }
90+ if b.data != nil {
91+ err = errors.Join(err, wlturbo.UnmapMemory(b.data))
92+ }
93+ return err
94+}
@@ -0,0 +1,94 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/wlturbo"
8+ "github.com/bnema/wlturbo/protocol/core"
9+ "golang.org/x/sys/unix"
10+
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+// bufGeom is the buffer layout announced by the compositor. It is reused only
15+// while the compositor keeps announcing the same one.
16+type bufGeom struct {
17+ format, width, height, stride uint32
18+ size int
19+}
20+
21+// validate bounds every dimension before any memory is created or mapped.
22+func (g *bufGeom) validate() error {
23+ if g.width < 1 || g.height < 1 || g.width > ports.MaxDimension || g.height > ports.MaxDimension {
24+ return fmt.Errorf("wayland: invalid buffer dimensions %dx%d", g.width, g.height)
25+ }
26+ if g.stride < g.width*ports.BytesPerPixel || g.stride%ports.BytesPerPixel != 0 || g.stride > ports.MaxFrameBytes/g.height {
27+ return fmt.Errorf("wayland: invalid buffer stride %d for %dx%d", g.stride, g.width, g.height)
28+ }
29+ g.size = int(g.stride) * int(g.height)
30+ return nil
31+}
32+
33+// shmBuffer is the single wl_shm mapping and wl_buffer reused between
34+// captures. The pool proxy is destroyed right after buffer creation, which the
35+// protocol allows: the buffer keeps the compositor-side mapping alive.
36+type shmBuffer struct {
37+ geom bufGeom
38+ data []byte
39+ buffer *core.Buffer
40+}
41+
42+// ensureBuffer makes s.buf match g, reusing it when unchanged.
43+func (s *Source) ensureBuffer(g bufGeom) error {
44+ if s.buf.buffer != nil && s.buf.geom == g {
45+ return nil
46+ }
47+ if err := s.releaseBuffer(true); err != nil {
48+ s.terminate()
49+ return err
50+ }
51+ fd, err := wlturbo.CreateAnonymousFile(int64(g.size))
52+ if err != nil {
53+ return fmt.Errorf("wayland: create shm file: %w", err)
54+ }
55+ data, err := wlturbo.MapMemory(fd, g.size)
56+ if err != nil {
57+ _ = unix.Close(fd)
58+ return fmt.Errorf("wayland: map shm file: %w", err)
59+ }
60+ // Context.Request closes fd once the message is sent, so on success the
61+ // descriptor belongs to the transport (the mapping outlives it). On error
62+ // the caller still owns it.
63+ pool, err := s.shm.CreatePool(fd, int32(g.size))
64+ if err != nil {
65+ _ = unix.Close(fd)
66+ _ = wlturbo.UnmapMemory(data)
67+ s.terminate()
68+ return fmt.Errorf("wayland: create pool: %w", err)
69+ }
70+ buffer, err := pool.CreateBuffer(0, int32(g.width), int32(g.height), int32(g.stride), g.format)
71+ _ = pool.Destroy()
72+ if err != nil {
73+ _ = wlturbo.UnmapMemory(data)
74+ s.terminate()
75+ return fmt.Errorf("wayland: create buffer: %w", err)
76+ }
77+ s.buf = shmBuffer{geom: g, data: data, buffer: buffer}
78+ return nil
79+}
80+
81+// releaseBuffer unmaps the storage. With destroy set, and while connected, it
82+// also destroys the wl_buffer. The caller holds opMu.
83+func (s *Source) releaseBuffer(destroy bool) error {
84+ b := s.buf
85+ s.buf = shmBuffer{}
86+ var err error
87+ if b.buffer != nil && destroy && !s.dead.Load() {
88+ err = b.buffer.Destroy()
89+ }
90+ if b.data != nil {
91+ err = errors.Join(err, wlturbo.UnmapMemory(b.data))
92+ }
93+ return err
94+}
A internal/adapters/wayland/source.go +351 -0
@@ -0,0 +1,351 @@
1+// Package wayland captures output frames over the wlr-screencopy protocol
2+// using the wlturbo transport and wl_shm buffers.
3+package wayland
4+
5+import (
6+ "cmp"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "net"
11+ "os"
12+ "path/filepath"
13+ "slices"
14+ "sync"
15+ "sync/atomic"
16+
17+ "github.com/bnema/wlturbo"
18+ "github.com/bnema/wlturbo/protocol/core"
19+ "github.com/bnema/zerowrap"
20+
21+ "github.com/bnema/nefercap/internal/logging"
22+ "github.com/bnema/nefercap/internal/ports"
23+)
24+
25+const (
26+ outputVersion = 4 // wl_output.name arrived in version 4
27+ shmVersion = 1
28+ screencopyVersion = 3
29+ screencopyIface = "zwlr_screencopy_manager_v1"
30+ maxSettleRounds = 4
31+ maxOutputScale = 16 // informational; larger announcements are clamped
32+)
33+
34+// output is the client-side state of one bound wl_output global.
35+type output struct {
36+ proxy *core.Output
37+ id uint32 // wl_registry global name, stable for the connection
38+ version uint32
39+ name string
40+ width, height int32
41+ scale int32
42+ removed bool
43+}
44+
45+// Source is a ports.Source backed by one Wayland connection.
46+//
47+// All protocol events are dispatched synchronously by the goroutine that runs
48+// Outputs or Capture, so event handlers need no locking. Calls are serialized
49+// by opMu. Cancelling the context of a running call, a protocol failure or
50+// Close closes the connection: the Source is then terminal and every later
51+// call returns ports.ErrClosed. The call that observed a cancellation returns
52+// its context's error. A context cancelled between calls affects only a later
53+// call that is given that same context.
54+type Source struct {
55+ log zerowrap.Logger
56+ display *wlturbo.Display
57+ wl *wlturbo.Context
58+
59+ opMu sync.Mutex // serializes operations and guards everything below
60+ dead atomic.Bool
61+
62+ // Cancellation watcher, reused while consecutive calls share a Done
63+ // channel. watchDone and watchStop are guarded by opMu; the callback only
64+ // reads the atomics, and acts only while a call is active and its
65+ // registration is still the current generation.
66+ watchDone <-chan struct{}
67+ watchStop func() bool
68+ watchGen atomic.Uint64
69+ watchActive atomic.Bool
70+
71+ shm *core.Shm
72+ screencopy *screencopyManager
73+ outputs map[uint32]*output
74+ bound int // wl_output globals bound so far
75+ buf shmBuffer
76+}
77+
78+var _ ports.Source = (*Source)(nil)
79+
80+// New connects to socket (an absolute path, or a name relative to
81+// XDG_RUNTIME_DIR; empty selects WAYLAND_DISPLAY) and discovers outputs.
82+//
83+// ctx bounds the constructor only: it interrupts discovery, and is detached
84+// once New returns, so cancelling it afterwards does not close the Source.
85+// Later calls are bound by their own contexts. The logger is derived from ctx.
86+func New(ctx context.Context, socket string) (*Source, error) {
87+ path, err := socketPath(socket)
88+ if err != nil {
89+ return nil, err
90+ }
91+ conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path)
92+ if err != nil {
93+ return nil, fmt.Errorf("wayland: connect: %w", err)
94+ }
95+ display, err := wlturbo.ConnectFromConn(conn)
96+ if err != nil {
97+ return nil, fmt.Errorf("wayland: %w", err)
98+ }
99+ s := &Source{
100+ log: logging.For(ctx, "wayland"),
101+ display: display,
102+ wl: display.Context(),
103+ outputs: make(map[uint32]*output),
104+ }
105+ stop := context.AfterFunc(ctx, s.terminate)
106+ err = s.discover()
107+ cancelled := !stop()
108+ if cancelled || err != nil {
109+ s.terminate()
110+ if cancelled || ctx.Err() != nil {
111+ err = ctx.Err()
112+ } else {
113+ err = fmt.Errorf("wayland: discover: %w", err)
114+ }
115+ s.log.Error().Err(err).Msg("wayland source failed to start")
116+ return nil, err
117+ }
118+ s.log.Info().Int("outputs", len(s.outputs)).Uint32("screencopy_version", s.screencopy.Version()).Msg("wayland source started")
119+ return s, nil
120+}
121+
122+func socketPath(socket string) (string, error) {
123+ if socket == "" {
124+ socket = os.Getenv("WAYLAND_DISPLAY")
125+ if socket == "" {
126+ socket = "wayland-0"
127+ }
128+ }
129+ if filepath.IsAbs(socket) {
130+ return socket, nil
131+ }
132+ dir := os.Getenv("XDG_RUNTIME_DIR")
133+ if dir == "" {
134+ return "", errors.New("wayland: XDG_RUNTIME_DIR is not set")
135+ }
136+ return filepath.Join(dir, socket), nil
137+}
138+
139+func (s *Source) discover() error {
140+ reg := s.display.Registry()
141+ reg.AddHandler(core.OutputInterface, s.onOutputGlobal)
142+ reg.AddGlobalRemoveHandler(globalRemoved{s})
143+ if err := s.roundtrip(); err != nil {
144+ return err
145+ }
146+ s.shm = core.NewShm(s.wl)
147+ if _, err := reg.BindNegotiated(core.ShmInterface, shmVersion, s.shm); err != nil {
148+ return fmt.Errorf("wayland: bind wl_shm: %w", err)
149+ }
150+ s.screencopy = &screencopyManager{}
151+ s.screencopy.SetContext(s.wl)
152+ if _, err := reg.BindNegotiated(screencopyIface, screencopyVersion, s.screencopy); err != nil {
153+ return fmt.Errorf("wayland: bind %s: %w", screencopyIface, err)
154+ }
155+ return s.roundtrip()
156+}
157+
158+func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) {
159+ if version == 0 || s.outputs[name] != nil {
160+ return // unusable, or a duplicate announcement of a bound global
161+ }
162+ o := &output{id: name, scale: 1, version: min(version, outputVersion), proxy: core.NewOutput(s.wl)}
163+ o.proxy.OnMode(func(flags uint32, w, h, _ int32) {
164+ // The mode is informational: implausible values are ignored.
165+ if flags&1 != 0 && w >= 0 && h >= 0 && w <= ports.MaxDimension && h <= ports.MaxDimension {
166+ o.width, o.height = w, h
167+ }
168+ })
169+ o.proxy.OnScale(func(f int32) { o.scale = min(max(f, 1), maxOutputScale) })
170+ o.proxy.OnName(func(n string) { o.name = n })
171+ if err := reg.Bind(name, core.OutputInterface, o.version, o.proxy); err != nil {
172+ s.log.Warn().Err(err).Uint32("id", name).Msg("bind wl_output failed")
173+ return
174+ }
175+ s.outputs[name] = o
176+ s.bound++
177+}
178+
179+type globalRemoved struct{ s *Source }
180+
181+func (g globalRemoved) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemoveEvent) {
182+ o := g.s.outputs[e.Name]
183+ if o == nil {
184+ return
185+ }
186+ delete(g.s.outputs, e.Name)
187+ o.removed = true
188+ if o.version >= 3 {
189+ _ = o.proxy.Release()
190+ } // older wl_output has no destructor: the proxy must stay to absorb events
191+}
192+
193+// terminate closes the connection, which unblocks any Dispatch. It is safe
194+// from any goroutine and idempotent.
195+func (s *Source) terminate() {
196+ s.dead.Store(true)
197+ _ = s.display.Close()
198+}
199+
200+func (s *Source) dispatch() error {
201+ err := s.display.Dispatch()
202+ if err != nil {
203+ s.terminate()
204+ return fmt.Errorf("wayland: dispatch: %w", err)
205+ }
206+ return nil
207+}
208+
209+// settle round-trips until no output was bound meanwhile, so that the events
210+// of an output announced during the last round trip have been received too.
211+func (s *Source) settle() error {
212+ for range maxSettleRounds {
213+ before := s.bound
214+ if err := s.roundtrip(); err != nil || s.bound == before {
215+ return err
216+ }
217+ }
218+ return nil
219+}
220+
221+func (s *Source) roundtrip() error {
222+ err := s.display.Roundtrip()
223+ if err != nil {
224+ s.terminate()
225+ return fmt.Errorf("wayland: roundtrip: %w", err)
226+ }
227+ return nil
228+}
229+
230+// begin starts one serialized operation bound by ctx. The caller must hold
231+// opMu and must call finish afterwards. Consecutive calls with the same Done
232+// channel share one context.AfterFunc registration; contexts that can never be
233+// cancelled register nothing.
234+func (s *Source) begin(ctx context.Context) error {
235+ if s.dead.Load() {
236+ return ports.ErrClosed
237+ }
238+ if done := ctx.Done(); done != nil {
239+ if done != s.watchDone {
240+ s.detachWatcher()
241+ gen := s.watchGen.Add(1)
242+ s.watchDone = done
243+ s.watchStop = context.AfterFunc(ctx, func() { s.watchFired(gen) })
244+ }
245+ // Activate before checking the context: a callback that fired while
246+ // idle did nothing, and this check then observes the cancellation.
247+ s.watchActive.Store(true)
248+ }
249+ if err := ctx.Err(); err != nil {
250+ s.watchActive.Store(false)
251+ s.terminate()
252+ s.detachWatcher()
253+ s.releaseBuffer(false)
254+ return err
255+ }
256+ return nil
257+}
258+
259+// watchFired runs on the context's goroutine. It ends the connection only
260+// when it belongs to the running call's registration.
261+func (s *Source) watchFired(gen uint64) {
262+ if s.watchActive.Load() && s.watchGen.Load() == gen {
263+ s.terminate()
264+ }
265+}
266+
267+// detachWatcher drops the cached registration. The caller holds opMu.
268+func (s *Source) detachWatcher() {
269+ if s.watchStop != nil {
270+ s.watchStop()
271+ }
272+ s.watchGen.Add(1) // a callback already running is now stale
273+ s.watchStop, s.watchDone = nil, nil
274+}
275+
276+// finish ends the call started by begin and classifies err. A cancellation
277+// observed at any point makes the Source terminal. When it is terminal, the
278+// mapped storage is released before returning.
279+func (s *Source) finish(ctx context.Context, err error) error {
280+ s.watchActive.Store(false)
281+ if cerr := ctx.Err(); cerr != nil {
282+ s.terminate()
283+ }
284+ if !s.dead.Load() {
285+ return err
286+ }
287+ s.detachWatcher()
288+ s.releaseBuffer(false)
289+ if cerr := ctx.Err(); cerr != nil {
290+ return cerr
291+ }
292+ if err == nil || errors.Is(err, net.ErrClosed) {
293+ return ports.ErrClosed
294+ }
295+ s.log.Error().Err(err).Msg("wayland source terminated")
296+ return err
297+}
298+
299+// Outputs lists the outputs currently announced by the compositor, by ID.
300+func (s *Source) Outputs(ctx context.Context) ([]ports.Output, error) {
301+ s.opMu.Lock()
302+ defer s.opMu.Unlock()
303+ if err := s.begin(ctx); err != nil {
304+ return nil, err
305+ }
306+ err := s.settle()
307+ var list []ports.Output
308+ if err == nil {
309+ list = make([]ports.Output, 0, len(s.outputs))
310+ for _, o := range s.outputs {
311+ name := o.name
312+ if name == "" { // wl_output older than version 4 has no name
313+ name = fmt.Sprintf("output-%d", o.id)
314+ }
315+ list = append(list, ports.Output{ID: o.id, Name: name, Width: int(o.width), Height: int(o.height), Scale: int(o.scale)})
316+ }
317+ slices.SortFunc(list, func(a, b ports.Output) int { return cmp.Compare(a.ID, b.ID) })
318+ }
319+ if err = s.finish(ctx, err); err != nil {
320+ return nil, err
321+ }
322+ return list, nil
323+}
324+
325+// Capture copies one frame of the target into storage reused across calls.
326+// The frame is valid until the next Capture or Close.
327+func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, error) {
328+ if err := validRegion(t.Region); err != nil {
329+ return ports.Frame{}, err
330+ }
331+ s.opMu.Lock()
332+ defer s.opMu.Unlock()
333+ if err := s.begin(ctx); err != nil {
334+ return ports.Frame{}, err
335+ }
336+ frame, err := s.capture(t)
337+ if err = s.finish(ctx, err); err != nil {
338+ return ports.Frame{}, err
339+ }
340+ return frame, nil
341+}
342+
343+// Close terminates the connection, waits for a blocked operation to observe
344+// it, and releases the mapped storage. It is idempotent.
345+func (s *Source) Close() error {
346+ s.terminate()
347+ s.opMu.Lock()
348+ defer s.opMu.Unlock()
349+ s.detachWatcher()
350+ return s.releaseBuffer(false)
351+}
@@ -0,0 +1,351 @@
1+// Package wayland captures output frames over the wlr-screencopy protocol
2+// using the wlturbo transport and wl_shm buffers.
3+package wayland
4+
5+import (
6+ "cmp"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "net"
11+ "os"
12+ "path/filepath"
13+ "slices"
14+ "sync"
15+ "sync/atomic"
16+
17+ "github.com/bnema/wlturbo"
18+ "github.com/bnema/wlturbo/protocol/core"
19+ "github.com/bnema/zerowrap"
20+
21+ "github.com/bnema/nefercap/internal/logging"
22+ "github.com/bnema/nefercap/internal/ports"
23+)
24+
25+const (
26+ outputVersion = 4 // wl_output.name arrived in version 4
27+ shmVersion = 1
28+ screencopyVersion = 3
29+ screencopyIface = "zwlr_screencopy_manager_v1"
30+ maxSettleRounds = 4
31+ maxOutputScale = 16 // informational; larger announcements are clamped
32+)
33+
34+// output is the client-side state of one bound wl_output global.
35+type output struct {
36+ proxy *core.Output
37+ id uint32 // wl_registry global name, stable for the connection
38+ version uint32
39+ name string
40+ width, height int32
41+ scale int32
42+ removed bool
43+}
44+
45+// Source is a ports.Source backed by one Wayland connection.
46+//
47+// All protocol events are dispatched synchronously by the goroutine that runs
48+// Outputs or Capture, so event handlers need no locking. Calls are serialized
49+// by opMu. Cancelling the context of a running call, a protocol failure or
50+// Close closes the connection: the Source is then terminal and every later
51+// call returns ports.ErrClosed. The call that observed a cancellation returns
52+// its context's error. A context cancelled between calls affects only a later
53+// call that is given that same context.
54+type Source struct {
55+ log zerowrap.Logger
56+ display *wlturbo.Display
57+ wl *wlturbo.Context
58+
59+ opMu sync.Mutex // serializes operations and guards everything below
60+ dead atomic.Bool
61+
62+ // Cancellation watcher, reused while consecutive calls share a Done
63+ // channel. watchDone and watchStop are guarded by opMu; the callback only
64+ // reads the atomics, and acts only while a call is active and its
65+ // registration is still the current generation.
66+ watchDone <-chan struct{}
67+ watchStop func() bool
68+ watchGen atomic.Uint64
69+ watchActive atomic.Bool
70+
71+ shm *core.Shm
72+ screencopy *screencopyManager
73+ outputs map[uint32]*output
74+ bound int // wl_output globals bound so far
75+ buf shmBuffer
76+}
77+
78+var _ ports.Source = (*Source)(nil)
79+
80+// New connects to socket (an absolute path, or a name relative to
81+// XDG_RUNTIME_DIR; empty selects WAYLAND_DISPLAY) and discovers outputs.
82+//
83+// ctx bounds the constructor only: it interrupts discovery, and is detached
84+// once New returns, so cancelling it afterwards does not close the Source.
85+// Later calls are bound by their own contexts. The logger is derived from ctx.
86+func New(ctx context.Context, socket string) (*Source, error) {
87+ path, err := socketPath(socket)
88+ if err != nil {
89+ return nil, err
90+ }
91+ conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path)
92+ if err != nil {
93+ return nil, fmt.Errorf("wayland: connect: %w", err)
94+ }
95+ display, err := wlturbo.ConnectFromConn(conn)
96+ if err != nil {
97+ return nil, fmt.Errorf("wayland: %w", err)
98+ }
99+ s := &Source{
100+ log: logging.For(ctx, "wayland"),
101+ display: display,
102+ wl: display.Context(),
103+ outputs: make(map[uint32]*output),
104+ }
105+ stop := context.AfterFunc(ctx, s.terminate)
106+ err = s.discover()
107+ cancelled := !stop()
108+ if cancelled || err != nil {
109+ s.terminate()
110+ if cancelled || ctx.Err() != nil {
111+ err = ctx.Err()
112+ } else {
113+ err = fmt.Errorf("wayland: discover: %w", err)
114+ }
115+ s.log.Error().Err(err).Msg("wayland source failed to start")
116+ return nil, err
117+ }
118+ s.log.Info().Int("outputs", len(s.outputs)).Uint32("screencopy_version", s.screencopy.Version()).Msg("wayland source started")
119+ return s, nil
120+}
121+
122+func socketPath(socket string) (string, error) {
123+ if socket == "" {
124+ socket = os.Getenv("WAYLAND_DISPLAY")
125+ if socket == "" {
126+ socket = "wayland-0"
127+ }
128+ }
129+ if filepath.IsAbs(socket) {
130+ return socket, nil
131+ }
132+ dir := os.Getenv("XDG_RUNTIME_DIR")
133+ if dir == "" {
134+ return "", errors.New("wayland: XDG_RUNTIME_DIR is not set")
135+ }
136+ return filepath.Join(dir, socket), nil
137+}
138+
139+func (s *Source) discover() error {
140+ reg := s.display.Registry()
141+ reg.AddHandler(core.OutputInterface, s.onOutputGlobal)
142+ reg.AddGlobalRemoveHandler(globalRemoved{s})
143+ if err := s.roundtrip(); err != nil {
144+ return err
145+ }
146+ s.shm = core.NewShm(s.wl)
147+ if _, err := reg.BindNegotiated(core.ShmInterface, shmVersion, s.shm); err != nil {
148+ return fmt.Errorf("wayland: bind wl_shm: %w", err)
149+ }
150+ s.screencopy = &screencopyManager{}
151+ s.screencopy.SetContext(s.wl)
152+ if _, err := reg.BindNegotiated(screencopyIface, screencopyVersion, s.screencopy); err != nil {
153+ return fmt.Errorf("wayland: bind %s: %w", screencopyIface, err)
154+ }
155+ return s.roundtrip()
156+}
157+
158+func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) {
159+ if version == 0 || s.outputs[name] != nil {
160+ return // unusable, or a duplicate announcement of a bound global
161+ }
162+ o := &output{id: name, scale: 1, version: min(version, outputVersion), proxy: core.NewOutput(s.wl)}
163+ o.proxy.OnMode(func(flags uint32, w, h, _ int32) {
164+ // The mode is informational: implausible values are ignored.
165+ if flags&1 != 0 && w >= 0 && h >= 0 && w <= ports.MaxDimension && h <= ports.MaxDimension {
166+ o.width, o.height = w, h
167+ }
168+ })
169+ o.proxy.OnScale(func(f int32) { o.scale = min(max(f, 1), maxOutputScale) })
170+ o.proxy.OnName(func(n string) { o.name = n })
171+ if err := reg.Bind(name, core.OutputInterface, o.version, o.proxy); err != nil {
172+ s.log.Warn().Err(err).Uint32("id", name).Msg("bind wl_output failed")
173+ return
174+ }
175+ s.outputs[name] = o
176+ s.bound++
177+}
178+
179+type globalRemoved struct{ s *Source }
180+
181+func (g globalRemoved) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemoveEvent) {
182+ o := g.s.outputs[e.Name]
183+ if o == nil {
184+ return
185+ }
186+ delete(g.s.outputs, e.Name)
187+ o.removed = true
188+ if o.version >= 3 {
189+ _ = o.proxy.Release()
190+ } // older wl_output has no destructor: the proxy must stay to absorb events
191+}
192+
193+// terminate closes the connection, which unblocks any Dispatch. It is safe
194+// from any goroutine and idempotent.
195+func (s *Source) terminate() {
196+ s.dead.Store(true)
197+ _ = s.display.Close()
198+}
199+
200+func (s *Source) dispatch() error {
201+ err := s.display.Dispatch()
202+ if err != nil {
203+ s.terminate()
204+ return fmt.Errorf("wayland: dispatch: %w", err)
205+ }
206+ return nil
207+}
208+
209+// settle round-trips until no output was bound meanwhile, so that the events
210+// of an output announced during the last round trip have been received too.
211+func (s *Source) settle() error {
212+ for range maxSettleRounds {
213+ before := s.bound
214+ if err := s.roundtrip(); err != nil || s.bound == before {
215+ return err
216+ }
217+ }
218+ return nil
219+}
220+
221+func (s *Source) roundtrip() error {
222+ err := s.display.Roundtrip()
223+ if err != nil {
224+ s.terminate()
225+ return fmt.Errorf("wayland: roundtrip: %w", err)
226+ }
227+ return nil
228+}
229+
230+// begin starts one serialized operation bound by ctx. The caller must hold
231+// opMu and must call finish afterwards. Consecutive calls with the same Done
232+// channel share one context.AfterFunc registration; contexts that can never be
233+// cancelled register nothing.
234+func (s *Source) begin(ctx context.Context) error {
235+ if s.dead.Load() {
236+ return ports.ErrClosed
237+ }
238+ if done := ctx.Done(); done != nil {
239+ if done != s.watchDone {
240+ s.detachWatcher()
241+ gen := s.watchGen.Add(1)
242+ s.watchDone = done
243+ s.watchStop = context.AfterFunc(ctx, func() { s.watchFired(gen) })
244+ }
245+ // Activate before checking the context: a callback that fired while
246+ // idle did nothing, and this check then observes the cancellation.
247+ s.watchActive.Store(true)
248+ }
249+ if err := ctx.Err(); err != nil {
250+ s.watchActive.Store(false)
251+ s.terminate()
252+ s.detachWatcher()
253+ s.releaseBuffer(false)
254+ return err
255+ }
256+ return nil
257+}
258+
259+// watchFired runs on the context's goroutine. It ends the connection only
260+// when it belongs to the running call's registration.
261+func (s *Source) watchFired(gen uint64) {
262+ if s.watchActive.Load() && s.watchGen.Load() == gen {
263+ s.terminate()
264+ }
265+}
266+
267+// detachWatcher drops the cached registration. The caller holds opMu.
268+func (s *Source) detachWatcher() {
269+ if s.watchStop != nil {
270+ s.watchStop()
271+ }
272+ s.watchGen.Add(1) // a callback already running is now stale
273+ s.watchStop, s.watchDone = nil, nil
274+}
275+
276+// finish ends the call started by begin and classifies err. A cancellation
277+// observed at any point makes the Source terminal. When it is terminal, the
278+// mapped storage is released before returning.
279+func (s *Source) finish(ctx context.Context, err error) error {
280+ s.watchActive.Store(false)
281+ if cerr := ctx.Err(); cerr != nil {
282+ s.terminate()
283+ }
284+ if !s.dead.Load() {
285+ return err
286+ }
287+ s.detachWatcher()
288+ s.releaseBuffer(false)
289+ if cerr := ctx.Err(); cerr != nil {
290+ return cerr
291+ }
292+ if err == nil || errors.Is(err, net.ErrClosed) {
293+ return ports.ErrClosed
294+ }
295+ s.log.Error().Err(err).Msg("wayland source terminated")
296+ return err
297+}
298+
299+// Outputs lists the outputs currently announced by the compositor, by ID.
300+func (s *Source) Outputs(ctx context.Context) ([]ports.Output, error) {
301+ s.opMu.Lock()
302+ defer s.opMu.Unlock()
303+ if err := s.begin(ctx); err != nil {
304+ return nil, err
305+ }
306+ err := s.settle()
307+ var list []ports.Output
308+ if err == nil {
309+ list = make([]ports.Output, 0, len(s.outputs))
310+ for _, o := range s.outputs {
311+ name := o.name
312+ if name == "" { // wl_output older than version 4 has no name
313+ name = fmt.Sprintf("output-%d", o.id)
314+ }
315+ list = append(list, ports.Output{ID: o.id, Name: name, Width: int(o.width), Height: int(o.height), Scale: int(o.scale)})
316+ }
317+ slices.SortFunc(list, func(a, b ports.Output) int { return cmp.Compare(a.ID, b.ID) })
318+ }
319+ if err = s.finish(ctx, err); err != nil {
320+ return nil, err
321+ }
322+ return list, nil
323+}
324+
325+// Capture copies one frame of the target into storage reused across calls.
326+// The frame is valid until the next Capture or Close.
327+func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, error) {
328+ if err := validRegion(t.Region); err != nil {
329+ return ports.Frame{}, err
330+ }
331+ s.opMu.Lock()
332+ defer s.opMu.Unlock()
333+ if err := s.begin(ctx); err != nil {
334+ return ports.Frame{}, err
335+ }
336+ frame, err := s.capture(t)
337+ if err = s.finish(ctx, err); err != nil {
338+ return ports.Frame{}, err
339+ }
340+ return frame, nil
341+}
342+
343+// Close terminates the connection, waits for a blocked operation to observe
344+// it, and releases the mapped storage. It is idempotent.
345+func (s *Source) Close() error {
346+ s.terminate()
347+ s.opMu.Lock()
348+ defer s.opMu.Unlock()
349+ s.detachWatcher()
350+ return s.releaseBuffer(false)
351+}
A internal/adapters/wayland/source_test.go +662 -0
@@ -0,0 +1,662 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "os"
6+ "runtime"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/nefercap/internal/ports"
13+)
14+
15+func newSource(t *testing.T, cfg compositorConfig) (*Source, *compositor) {
16+ t.Helper()
17+ comp := startCompositor(t, cfg)
18+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
19+ defer cancel()
20+ s, err := New(ctx, comp.path)
21+ require.NoError(t, err)
22+ t.Cleanup(func() { _ = s.Close() })
23+ return s, comp
24+}
25+
26+func firstOutput(t *testing.T, s *Source) ports.Output {
27+ t.Helper()
28+ outs, err := s.Outputs(context.Background())
29+ require.NoError(t, err)
30+ require.NotEmpty(t, outs)
31+ return outs[0]
32+}
33+
34+func fdCount(t *testing.T) int {
35+ t.Helper()
36+ entries, err := os.ReadDir("/proc/self/fd")
37+ require.NoError(t, err)
38+ return len(entries)
39+}
40+
41+func TestOutputs(t *testing.T) {
42+ s, _ := newSource(t, compositorConfig{outputs: []outputSpec{
43+ {name: "DP-1", width: 8, height: 4, scale: 2, version: 4},
44+ {name: "ignored", width: 6, height: 2, scale: 1, version: 3},
45+ }})
46+ outs, err := s.Outputs(context.Background())
47+ require.NoError(t, err)
48+ require.Equal(t, []ports.Output{
49+ {ID: 1, Name: "DP-1", Width: 8, Height: 4, Scale: 2},
50+ {ID: 2, Name: "output-2", Width: 6, Height: 2, Scale: 1}, // no wl_output.name before version 4
51+ }, outs)
52+}
53+
54+func TestOutputRemovedBetweenCalls(t *testing.T) {
55+ s, comp := newSource(t, compositorConfig{outputs: []outputSpec{
56+ {name: "A", width: 4, height: 4, scale: 1, version: 4},
57+ {name: "B", width: 4, height: 4, scale: 1, version: 4},
58+ }})
59+ comp.removeOutput(1)
60+ outs, err := s.Outputs(context.Background())
61+ require.NoError(t, err)
62+ require.Len(t, outs, 1)
63+ require.Equal(t, "B", outs[0].Name)
64+ _, err = s.Capture(context.Background(), ports.Target{OutputID: 1})
65+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
66+ // Not finding an output is not a connection failure.
67+ _, err = s.Capture(context.Background(), ports.Target{OutputID: 2})
68+ require.NoError(t, err)
69+}
70+
71+func TestOutputRemovedDuringCapture(t *testing.T) {
72+ s, _ := newSource(t, compositorConfig{removeOnCapture: true})
73+ out := firstOutput(t, s)
74+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
75+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
76+ outs, err := s.Outputs(context.Background())
77+ require.NoError(t, err)
78+ require.Empty(t, outs)
79+}
80+
81+func TestCaptureFullOutput(t *testing.T) {
82+ s, comp := newSource(t, compositorConfig{})
83+ out := firstOutput(t, s)
84+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
85+ require.NoError(t, err)
86+ require.NoError(t, f.Validate())
87+ require.Equal(t, 8, f.Width)
88+ require.Equal(t, 4, f.Height)
89+ require.Equal(t, 32, f.Stride)
90+ require.Equal(t, ports.XRGB8888, f.Format)
91+ require.False(t, f.YInvert)
92+ for y := 0; y < f.Height; y++ {
93+ row := f.Row(y)
94+ for x := 0; x < f.Width; x++ {
95+ want := pixel(x, y, 1)
96+ require.Equal(t, want[:], row[x*4:x*4+4], "pixel %d,%d", x, y)
97+ }
98+ }
99+ require.Zero(t, comp.stat(func(c *compositor) int { return int(c.lastRegion[2]) }), "full output must not send a region")
100+}
101+
102+func TestCaptureStrideFormatAndYInvert(t *testing.T) {
103+ s, _ := newSource(t, compositorConfig{
104+ announce: []announce{{format: 0, width: 5, height: 3, stride: 5*4 + 12}},
105+ flags: 1,
106+ })
107+ out := firstOutput(t, s)
108+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
109+ require.NoError(t, err)
110+ require.NoError(t, f.Validate())
111+ require.Equal(t, ports.ARGB8888, f.Format)
112+ require.Equal(t, 32, f.Stride)
113+ require.Equal(t, 5, f.Width)
114+ require.Equal(t, 3, f.Height)
115+ require.True(t, f.YInvert)
116+ require.Len(t, f.Pixels, 32*3)
117+ // Row(0) is the top row, stored last when y-inverted.
118+ want := pixel(0, 2, 1)
119+ require.Equal(t, want[:], f.Row(0)[:4])
120+ require.Len(t, f.Row(0), 20)
121+ // Padding is not part of a row but is preserved in storage.
122+ require.Equal(t, byte(0xEE), f.Pixels[20])
123+}
124+
125+func TestCaptureRegion(t *testing.T) {
126+ s, comp := newSource(t, compositorConfig{})
127+ out := firstOutput(t, s)
128+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 1, Y: 2, Width: 3, Height: 1}})
129+ require.NoError(t, err)
130+ require.Equal(t, 3, f.Width)
131+ require.Equal(t, 1, f.Height)
132+ comp.mu.Lock()
133+ defer comp.mu.Unlock()
134+ require.True(t, comp.lastRegionSet)
135+ require.Equal(t, [4]int32{1, 2, 3, 1}, comp.lastRegion)
136+}
137+
138+func TestCaptureInvalidRegion(t *testing.T) {
139+ s, comp := newSource(t, compositorConfig{})
140+ out := firstOutput(t, s)
141+ for _, r := range []ports.Region{
142+ {X: -1, Y: 0, Width: 1, Height: 1},
143+ {X: 0, Y: -1, Width: 1, Height: 1},
144+ {X: 0, Y: 0, Width: 0, Height: 1},
145+ {X: 0, Y: 0, Width: 1, Height: -3},
146+ {X: 0, Y: 0, Width: ports.MaxDimension + 1, Height: 1},
147+ {X: 1 << 40, Y: 0, Width: 1, Height: 1},
148+ } {
149+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: r})
150+ require.ErrorIs(t, err, ports.ErrInvalidRegion, "%+v", r)
151+ }
152+ require.Zero(t, comp.stat(func(c *compositor) int { return c.captures }))
153+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
154+ require.NoError(t, err, "invalid regions must not end the session")
155+}
156+
157+func TestCaptureUnknownOutput(t *testing.T) {
158+ s, _ := newSource(t, compositorConfig{})
159+ _, err := s.Capture(context.Background(), ports.Target{OutputID: 99})
160+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
161+ _, err = s.Capture(context.Background(), ports.Target{OutputID: 1})
162+ require.NoError(t, err)
163+}
164+
165+func TestCaptureRejectsBadBuffers(t *testing.T) {
166+ const xrgb = 1
167+ cases := map[string]announce{
168+ "zero width": {xrgb, 0, 4, 16},
169+ "zero height": {xrgb, 4, 0, 16},
170+ "wide": {xrgb, ports.MaxDimension + 1, 1, (ports.MaxDimension + 1) * 4},
171+ "tall": {xrgb, 1, ports.MaxDimension + 1, 4},
172+ "short stride": {xrgb, 4, 4, 12},
173+ "unaligned stride": {xrgb, 4, 4, 18},
174+ "over 256 MiB": {xrgb, 16384, 16384, 16384 * 4},
175+ "huge stride": {xrgb, 4, 4, 1 << 31},
176+ "stride overflow": {xrgb, 4, 2, 0xffffffff - 3},
177+ }
178+ for name, a := range cases {
179+ t.Run(name, func(t *testing.T) {
180+ s, comp := newSource(t, compositorConfig{announce: []announce{a}})
181+ out := firstOutput(t, s)
182+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
183+ require.Error(t, err)
184+ require.Zero(t, comp.stat(func(c *compositor) int { return c.pools }), "nothing may be allocated before validation")
185+ require.Zero(t, comp.stat(func(c *compositor) int { return c.copies }))
186+ _, err = s.Outputs(context.Background())
187+ require.NoError(t, err, "a rejected announcement leaves the connection usable")
188+ })
189+ }
190+}
191+
192+func TestCaptureUnsupportedFormat(t *testing.T) {
193+ for _, format := range []uint32{2, 0x34325258, 0x34324752, 0xffffffff} { // C8, XR24-like fourcc, RG24, garbage
194+ s, comp := newSource(t, compositorConfig{announce: []announce{{format, 4, 4, 16}}})
195+ out := firstOutput(t, s)
196+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
197+ require.ErrorIs(t, err, ports.ErrUnsupportedFormat, "format %#x", format)
198+ require.Zero(t, comp.stat(func(c *compositor) int { return c.pools }))
199+ }
200+}
201+
202+func TestCapturePicksSupportedAnnouncement(t *testing.T) {
203+ s, _ := newSource(t, compositorConfig{announce: []announce{{0x34325258, 4, 4, 16}, {1, 3, 2, 12}}})
204+ out := firstOutput(t, s)
205+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
206+ require.NoError(t, err)
207+ require.Equal(t, 3, f.Width)
208+ require.Equal(t, ports.XRGB8888, f.Format)
209+}
210+
211+func TestCaptureWithoutBufferDone(t *testing.T) {
212+ s, _ := newSource(t, compositorConfig{screencopyVersion: 1})
213+ out := firstOutput(t, s)
214+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
215+ require.NoError(t, err)
216+ require.NoError(t, f.Validate())
217+}
218+
219+func TestMissingScreencopy(t *testing.T) {
220+ comp := startCompositor(t, compositorConfig{noScreencopy: true})
221+ _, err := New(context.Background(), comp.path)
222+ require.Error(t, err)
223+}
224+
225+func TestReuseAndGeometryChange(t *testing.T) {
226+ s, comp := newSource(t, compositorConfig{})
227+ out := firstOutput(t, s)
228+ target := ports.Target{OutputID: out.ID}
229+ f1, err := s.Capture(context.Background(), target)
230+ require.NoError(t, err)
231+ first := &f1.Pixels[0]
232+ for i := 2; i <= 5; i++ {
233+ f, err := s.Capture(context.Background(), target)
234+ require.NoError(t, err)
235+ require.Same(t, first, &f.Pixels[0], "storage must be reused")
236+ want := pixel(3, 1, uint32(i))
237+ require.Equal(t, want[:], f.Row(1)[12:16], "capture %d must show fresh content", i)
238+ }
239+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.pools }))
240+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.buffers }))
241+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.fdsReceived }))
242+
243+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 2, Height: 2}})
244+ require.NoError(t, err)
245+ require.Equal(t, 2, f.Width)
246+ require.Equal(t, 2, comp.stat(func(c *compositor) int { return c.pools }))
247+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.destroyedBuffers }))
248+}
249+
250+func TestNoDescriptorLeak(t *testing.T) {
251+ s, comp := newSource(t, compositorConfig{})
252+ out := firstOutput(t, s)
253+ target := ports.Target{OutputID: out.ID}
254+ _, err := s.Capture(context.Background(), target)
255+ require.NoError(t, err)
256+ steady := fdCount(t)
257+ for i := 0; i < 30; i++ {
258+ // Alternate geometry: every switch creates a pool and must not keep its descriptor.
259+ region := ports.Region{Width: 2 + i%2, Height: 2}
260+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: region})
261+ require.NoError(t, err)
262+ _, err = s.Capture(context.Background(), target)
263+ require.NoError(t, err)
264+ }
265+ require.Equal(t, 61, comp.stat(func(c *compositor) int { return c.fdsReceived }))
266+ require.Equal(t, steady, fdCount(t))
267+
268+ require.NoError(t, s.Close())
269+ require.Eventually(t, func() bool { return fdCount(t) <= steady-2 }, 2*time.Second, 5*time.Millisecond,
270+ "connection descriptors must be closed on Close")
271+}
272+
273+func TestCancelInterruptsBlockedCapture(t *testing.T) {
274+ s, comp := newSource(t, compositorConfig{hold: true})
275+ out := firstOutput(t, s)
276+ ctx, cancel := context.WithCancel(context.Background())
277+ errc := make(chan error, 1)
278+ go func() {
279+ _, err := s.Capture(ctx, ports.Target{OutputID: out.ID})
280+ errc <- err
281+ }()
282+ <-comp.copyEvent
283+ cancel()
284+ select {
285+ case err := <-errc:
286+ require.ErrorIs(t, err, context.Canceled)
287+ case <-time.After(3 * time.Second):
288+ t.Fatal("capture not interrupted")
289+ }
290+ _, err := s.Outputs(context.Background())
291+ require.ErrorIs(t, err, ports.ErrClosed, "cancellation is not recoverable")
292+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})
293+ require.ErrorIs(t, err, ports.ErrClosed)
294+ require.NoError(t, s.Close())
295+}
296+
297+func TestCloseInterruptsBlockedCapture(t *testing.T) {
298+ s, comp := newSource(t, compositorConfig{hold: true})
299+ out := firstOutput(t, s)
300+ errc := make(chan error, 1)
301+ go func() {
302+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
303+ errc <- err
304+ }()
305+ <-comp.copyEvent
306+ require.NoError(t, s.Close())
307+ select {
308+ case err := <-errc:
309+ require.ErrorIs(t, err, ports.ErrClosed)
310+ case <-time.After(3 * time.Second):
311+ t.Fatal("capture not interrupted")
312+ }
313+ require.NoError(t, s.Close())
314+ _, err := s.Outputs(context.Background())
315+ require.ErrorIs(t, err, ports.ErrClosed)
316+}
317+
318+func TestCancelledContextBeforeCall(t *testing.T) {
319+ s, _ := newSource(t, compositorConfig{})
320+ ctx, cancel := context.WithCancel(context.Background())
321+ cancel()
322+ _, err := s.Outputs(ctx)
323+ require.ErrorIs(t, err, context.Canceled)
324+ _, err = s.Outputs(context.Background())
325+ require.ErrorIs(t, err, ports.ErrClosed)
326+}
327+
328+func TestCompositorDisconnect(t *testing.T) {
329+ s, comp := newSource(t, compositorConfig{hold: true})
330+ out := firstOutput(t, s)
331+ errc := make(chan error, 1)
332+ go func() {
333+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
334+ errc <- err
335+ }()
336+ <-comp.copyEvent
337+ comp.mu.Lock()
338+ comp.conn.Close()
339+ comp.mu.Unlock()
340+ select {
341+ case err := <-errc:
342+ require.Error(t, err)
343+ case <-time.After(3 * time.Second):
344+ t.Fatal("capture not interrupted")
345+ }
346+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
347+ require.ErrorIs(t, err, ports.ErrClosed)
348+}
349+
350+func TestConstructorContextIsScopedToConstructor(t *testing.T) {
351+ comp := startCompositor(t, compositorConfig{})
352+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
353+ s, err := New(ctx, comp.path)
354+ require.NoError(t, err)
355+ defer s.Close()
356+ cancel()
357+ time.Sleep(20 * time.Millisecond)
358+ out := firstOutput(t, s)
359+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})
360+ require.NoError(t, err, "cancelling the constructor context must not close the source")
361+}
362+
363+func TestConstructorTimeout(t *testing.T) {
364+ comp := startCompositor(t, compositorConfig{silent: true})
365+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
366+ defer cancel()
367+ start := time.Now()
368+ _, err := New(ctx, comp.path)
369+ require.ErrorIs(t, err, context.DeadlineExceeded)
370+ require.Less(t, time.Since(start), 2*time.Second)
371+}
372+
373+func TestConstructorConnectFailure(t *testing.T) {
374+ _, err := New(context.Background(), t.TempDir()+"/missing")
375+ require.Error(t, err)
376+ t.Setenv("XDG_RUNTIME_DIR", "")
377+ _, err = New(context.Background(), "relative")
378+ require.Error(t, err)
379+}
380+
381+func TestConcurrentCallsAreSerialized(t *testing.T) {
382+ s, _ := newSource(t, compositorConfig{})
383+ out := firstOutput(t, s)
384+ errc := make(chan error, 8)
385+ for range 8 {
386+ go func() {
387+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
388+ if err == nil {
389+ _, err = s.Outputs(context.Background())
390+ }
391+ errc <- err
392+ }()
393+ }
394+ for range 8 {
395+ require.NoError(t, <-errc)
396+ }
397+}
398+
399+// Steady state cost of one capture over the real transport. The measurement
400+// includes the in-process protocol peer, so it is an upper bound for the
401+// Source alone; the budget is set from measured values (see the benchmark).
402+func TestCaptureAllocations(t *testing.T) {
403+ if raceEnabled {
404+ t.Skip("race instrumentation changes allocation counts")
405+ }
406+ s, _ := newSource(t, compositorConfig{outputs: []outputSpec{{name: "X", width: 64, height: 64, scale: 1, version: 4}}})
407+ out := firstOutput(t, s)
408+ ctx := context.Background()
409+ target := ports.Target{OutputID: out.ID}
410+ for range 3 { // warm pools and buffers
411+ _, err := s.Capture(ctx, target)
412+ require.NoError(t, err)
413+ }
414+ runtime.GC()
415+ avg := testing.AllocsPerRun(200, func() {
416+ if _, err := s.Capture(ctx, target); err != nil {
417+ panic(err)
418+ }
419+ })
420+ t.Logf("allocations per capture (client and in-process peer): %g", avg)
421+ const budget = 14 // measured 12 (client and in-process peer), with headroom
422+ if avg > budget {
423+ t.Fatalf("allocations per capture %g exceed budget %d", avg, budget)
424+ }
425+ cancelable, cancel := context.WithCancel(ctx)
426+ defer cancel()
427+ avgCancelable := testing.AllocsPerRun(200, func() {
428+ if _, err := s.Capture(cancelable, target); err != nil {
429+ panic(err)
430+ }
431+ })
432+ t.Logf("allocations per capture with cancellable context: %g", avgCancelable)
433+ if avgCancelable > budget {
434+ t.Fatalf("cancellable capture allocations %g exceed budget", avgCancelable)
435+ }
436+}
437+
438+func BenchmarkCapture(b *testing.B) {
439+ comp := startCompositor(b, compositorConfig{outputs: []outputSpec{{name: "BENCH", width: 1920, height: 1080, scale: 1, version: 4}}})
440+ s, err := New(context.Background(), comp.path)
441+ if err != nil {
442+ b.Fatal(err)
443+ }
444+ defer s.Close()
445+ outs, err := s.Outputs(context.Background())
446+ if err != nil || len(outs) == 0 {
447+ b.Fatal(err)
448+ }
449+ target := ports.Target{OutputID: outs[0].ID}
450+ ctx := context.Background()
451+ b.ReportAllocs()
452+ b.SetBytes(1920 * 1080 * 4)
453+ b.ResetTimer()
454+ for b.Loop() {
455+ if _, err := s.Capture(ctx, target); err != nil {
456+ b.Fatal(err)
457+ }
458+ }
459+}
460+
461+func TestCaptureWaitsForReady(t *testing.T) {
462+ s, comp := newSource(t, compositorConfig{hold: true})
463+ out := firstOutput(t, s)
464+ type result struct {
465+ f ports.Frame
466+ err error
467+ }
468+ res := make(chan result, 1)
469+ go func() {
470+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
471+ res <- result{f, err}
472+ }()
473+ <-comp.copyEvent
474+ select {
475+ case <-res:
476+ t.Fatal("capture returned before the compositor completed the copy")
477+ case <-time.After(50 * time.Millisecond):
478+ }
479+ comp.completePending()
480+ r := <-res
481+ require.NoError(t, r.err)
482+ want := pixel(1, 1, 1)
483+ require.Equal(t, want[:], r.f.Row(1)[4:8])
484+}
485+
486+// Opt-in: NEFERCAP_WAYLAND_SOCKET names the socket of a disposable headless
487+// compositor that supports wlr-screencopy. Never point it at a real session.
488+func TestHeadlessCompositor(t *testing.T) {
489+ socket := os.Getenv("NEFERCAP_WAYLAND_SOCKET")
490+ if socket == "" {
491+ t.Skip("NEFERCAP_WAYLAND_SOCKET not set")
492+ }
493+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
494+ defer cancel()
495+ s, err := New(ctx, socket)
496+ require.NoError(t, err)
497+ defer s.Close()
498+ out := firstOutput(t, s)
499+ require.NotEmpty(t, out.Name)
500+ for range 3 {
501+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
502+ require.NoError(t, err)
503+ require.NoError(t, f.Validate())
504+ }
505+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 16, Height: 8}})
506+ require.NoError(t, err)
507+ require.Equal(t, 16, f.Width)
508+ require.Equal(t, 8, f.Height)
509+}
510+
511+func TestIdleCancellationDoesNotKillSource(t *testing.T) {
512+ s, _ := newSource(t, compositorConfig{})
513+ out := firstOutput(t, s)
514+ target := ports.Target{OutputID: out.ID}
515+ ctx, cancel := context.WithCancel(context.Background())
516+ _, err := s.Capture(ctx, target)
517+ require.NoError(t, err)
518+ _, err = s.Capture(ctx, target) // same context: the watcher stays registered
519+ require.NoError(t, err)
520+ cancel()
521+ _, err = s.Capture(context.Background(), target)
522+ require.NoError(t, err, "a context cancelled between calls must not end the source")
523+ _, err = s.Outputs(ctx)
524+ require.ErrorIs(t, err, context.Canceled, "the next call given the cancelled context observes it")
525+ _, err = s.Capture(context.Background(), target)
526+ require.ErrorIs(t, err, ports.ErrClosed)
527+}
528+
529+func TestStaleContextDoesNotAffectLaterCall(t *testing.T) {
530+ s, comp := newSource(t, compositorConfig{hold: true})
531+ out := firstOutput(t, s)
532+ target := ports.Target{OutputID: out.ID}
533+ ctxA, cancelA := context.WithCancel(context.Background())
534+ ctxB, cancelB := context.WithCancel(context.Background())
535+ defer cancelB()
536+
537+ runCapture := func(ctx context.Context) chan error {
538+ errc := make(chan error, 1)
539+ go func() {
540+ _, err := s.Capture(ctx, target)
541+ errc <- err
542+ }()
543+ <-comp.copyEvent
544+ return errc
545+ }
546+ errc := runCapture(ctxA)
547+ comp.completePending()
548+ require.NoError(t, <-errc)
549+
550+ errc = runCapture(ctxB) // blocked under B while A's old registration is replaced
551+ cancelA()
552+ select {
553+ case err := <-errc:
554+ t.Fatalf("cancelling a previous call's context ended a later call: %v", err)
555+ case <-time.After(50 * time.Millisecond):
556+ }
557+ comp.completePending()
558+ require.NoError(t, <-errc)
559+
560+ errc = runCapture(ctxB)
561+ cancelB()
562+ select {
563+ case err := <-errc:
564+ require.ErrorIs(t, err, context.Canceled)
565+ case <-time.After(3 * time.Second):
566+ t.Fatal("blocked capture not interrupted")
567+ }
568+ s.opMu.Lock()
569+ released := s.buf.data == nil && s.watchStop == nil
570+ s.opMu.Unlock()
571+ require.True(t, released, "terminal source must release its mapping and watcher")
572+ _, err := s.Outputs(context.Background())
573+ require.ErrorIs(t, err, ports.ErrClosed)
574+}
575+
576+func TestContextSwitchRace(t *testing.T) {
577+ s, _ := newSource(t, compositorConfig{})
578+ out := firstOutput(t, s)
579+ target := ports.Target{OutputID: out.ID}
580+ for i := 0; i < 200; i++ {
581+ ctx, cancel := context.WithCancel(context.Background())
582+ _, err := s.Capture(ctx, target)
583+ require.NoError(t, err)
584+ go cancel() // races with the next call, which uses another context
585+ _, err = s.Capture(context.Background(), target)
586+ require.NoError(t, err)
587+ cancel()
588+ }
589+}
590+
591+func TestNoShmOffer(t *testing.T) {
592+ s, comp := newSource(t, compositorConfig{noShmOffer: true})
593+ out := firstOutput(t, s)
594+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
595+ require.ErrorIs(t, err, ports.ErrUnsupportedFormat)
596+ require.ErrorContains(t, err, "no wl_shm buffer")
597+ require.Zero(t, comp.stat(func(c *compositor) int { return c.pools }))
598+ _, err = s.Outputs(context.Background())
599+ require.NoError(t, err)
600+}
601+
602+func TestBufferDoneOnlyAfterShmOffer(t *testing.T) {
603+ // The default peer sends buffer then buffer_done: capture waits for the latter.
604+ s, _ := newSource(t, compositorConfig{})
605+ out := firstOutput(t, s)
606+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
607+ require.NoError(t, err)
608+}
609+
610+func TestMalformedOutputModeAndScale(t *testing.T) {
611+ s, _ := newSource(t, compositorConfig{outputs: []outputSpec{
612+ {name: "BAD", width: 0xffffffff, height: 1 << 30, scale: -4, version: 4},
613+ {name: "HUGE", width: 100, height: 50, scale: 1000, version: 4},
614+ }})
615+ outs, err := s.Outputs(context.Background())
616+ require.NoError(t, err)
617+ require.Equal(t, []ports.Output{
618+ {ID: 1, Name: "BAD", Scale: 1},
619+ {ID: 2, Name: "HUGE", Width: 100, Height: 50, Scale: maxOutputScale},
620+ }, outs)
621+}
622+
623+func TestDuplicateGlobalIgnored(t *testing.T) {
624+ s, _ := newSource(t, compositorConfig{duplicateGlobal: true})
625+ outs, err := s.Outputs(context.Background())
626+ require.NoError(t, err)
627+ require.Len(t, outs, 1)
628+ require.Equal(t, 1, s.bound)
629+}
630+
631+func TestReadyWithoutCopyIsTerminal(t *testing.T) {
632+ s, _ := newSource(t, compositorConfig{readyEarly: true})
633+ out := firstOutput(t, s)
634+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
635+ require.Error(t, err)
636+ _, err = s.Outputs(context.Background())
637+ require.ErrorIs(t, err, ports.ErrClosed)
638+}
639+
640+func TestCompositorFailureIsRecoverable(t *testing.T) {
641+ t.Run("before copy", func(t *testing.T) {
642+ s, comp := newSource(t, compositorConfig{failCapture: true})
643+ out := firstOutput(t, s)
644+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
645+ require.Error(t, err)
646+ comp.mu.Lock()
647+ comp.cfg.failCapture = false
648+ comp.mu.Unlock()
649+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})
650+ require.NoError(t, err)
651+ })
652+ t.Run("after copy", func(t *testing.T) {
653+ s, _ := newSource(t, compositorConfig{failCopies: 1})
654+ out := firstOutput(t, s)
655+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
656+ require.Error(t, err)
657+ require.NotErrorIs(t, err, ports.ErrClosed)
658+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
659+ require.NoError(t, err)
660+ require.NoError(t, f.Validate())
661+ })
662+}
@@ -0,0 +1,662 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "os"
6+ "runtime"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/nefercap/internal/ports"
13+)
14+
15+func newSource(t *testing.T, cfg compositorConfig) (*Source, *compositor) {
16+ t.Helper()
17+ comp := startCompositor(t, cfg)
18+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
19+ defer cancel()
20+ s, err := New(ctx, comp.path)
21+ require.NoError(t, err)
22+ t.Cleanup(func() { _ = s.Close() })
23+ return s, comp
24+}
25+
26+func firstOutput(t *testing.T, s *Source) ports.Output {
27+ t.Helper()
28+ outs, err := s.Outputs(context.Background())
29+ require.NoError(t, err)
30+ require.NotEmpty(t, outs)
31+ return outs[0]
32+}
33+
34+func fdCount(t *testing.T) int {
35+ t.Helper()
36+ entries, err := os.ReadDir("/proc/self/fd")
37+ require.NoError(t, err)
38+ return len(entries)
39+}
40+
41+func TestOutputs(t *testing.T) {
42+ s, _ := newSource(t, compositorConfig{outputs: []outputSpec{
43+ {name: "DP-1", width: 8, height: 4, scale: 2, version: 4},
44+ {name: "ignored", width: 6, height: 2, scale: 1, version: 3},
45+ }})
46+ outs, err := s.Outputs(context.Background())
47+ require.NoError(t, err)
48+ require.Equal(t, []ports.Output{
49+ {ID: 1, Name: "DP-1", Width: 8, Height: 4, Scale: 2},
50+ {ID: 2, Name: "output-2", Width: 6, Height: 2, Scale: 1}, // no wl_output.name before version 4
51+ }, outs)
52+}
53+
54+func TestOutputRemovedBetweenCalls(t *testing.T) {
55+ s, comp := newSource(t, compositorConfig{outputs: []outputSpec{
56+ {name: "A", width: 4, height: 4, scale: 1, version: 4},
57+ {name: "B", width: 4, height: 4, scale: 1, version: 4},
58+ }})
59+ comp.removeOutput(1)
60+ outs, err := s.Outputs(context.Background())
61+ require.NoError(t, err)
62+ require.Len(t, outs, 1)
63+ require.Equal(t, "B", outs[0].Name)
64+ _, err = s.Capture(context.Background(), ports.Target{OutputID: 1})
65+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
66+ // Not finding an output is not a connection failure.
67+ _, err = s.Capture(context.Background(), ports.Target{OutputID: 2})
68+ require.NoError(t, err)
69+}
70+
71+func TestOutputRemovedDuringCapture(t *testing.T) {
72+ s, _ := newSource(t, compositorConfig{removeOnCapture: true})
73+ out := firstOutput(t, s)
74+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
75+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
76+ outs, err := s.Outputs(context.Background())
77+ require.NoError(t, err)
78+ require.Empty(t, outs)
79+}
80+
81+func TestCaptureFullOutput(t *testing.T) {
82+ s, comp := newSource(t, compositorConfig{})
83+ out := firstOutput(t, s)
84+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
85+ require.NoError(t, err)
86+ require.NoError(t, f.Validate())
87+ require.Equal(t, 8, f.Width)
88+ require.Equal(t, 4, f.Height)
89+ require.Equal(t, 32, f.Stride)
90+ require.Equal(t, ports.XRGB8888, f.Format)
91+ require.False(t, f.YInvert)
92+ for y := 0; y < f.Height; y++ {
93+ row := f.Row(y)
94+ for x := 0; x < f.Width; x++ {
95+ want := pixel(x, y, 1)
96+ require.Equal(t, want[:], row[x*4:x*4+4], "pixel %d,%d", x, y)
97+ }
98+ }
99+ require.Zero(t, comp.stat(func(c *compositor) int { return int(c.lastRegion[2]) }), "full output must not send a region")
100+}
101+
102+func TestCaptureStrideFormatAndYInvert(t *testing.T) {
103+ s, _ := newSource(t, compositorConfig{
104+ announce: []announce{{format: 0, width: 5, height: 3, stride: 5*4 + 12}},
105+ flags: 1,
106+ })
107+ out := firstOutput(t, s)
108+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
109+ require.NoError(t, err)
110+ require.NoError(t, f.Validate())
111+ require.Equal(t, ports.ARGB8888, f.Format)
112+ require.Equal(t, 32, f.Stride)
113+ require.Equal(t, 5, f.Width)
114+ require.Equal(t, 3, f.Height)
115+ require.True(t, f.YInvert)
116+ require.Len(t, f.Pixels, 32*3)
117+ // Row(0) is the top row, stored last when y-inverted.
118+ want := pixel(0, 2, 1)
119+ require.Equal(t, want[:], f.Row(0)[:4])
120+ require.Len(t, f.Row(0), 20)
121+ // Padding is not part of a row but is preserved in storage.
122+ require.Equal(t, byte(0xEE), f.Pixels[20])
123+}
124+
125+func TestCaptureRegion(t *testing.T) {
126+ s, comp := newSource(t, compositorConfig{})
127+ out := firstOutput(t, s)
128+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 1, Y: 2, Width: 3, Height: 1}})
129+ require.NoError(t, err)
130+ require.Equal(t, 3, f.Width)
131+ require.Equal(t, 1, f.Height)
132+ comp.mu.Lock()
133+ defer comp.mu.Unlock()
134+ require.True(t, comp.lastRegionSet)
135+ require.Equal(t, [4]int32{1, 2, 3, 1}, comp.lastRegion)
136+}
137+
138+func TestCaptureInvalidRegion(t *testing.T) {
139+ s, comp := newSource(t, compositorConfig{})
140+ out := firstOutput(t, s)
141+ for _, r := range []ports.Region{
142+ {X: -1, Y: 0, Width: 1, Height: 1},
143+ {X: 0, Y: -1, Width: 1, Height: 1},
144+ {X: 0, Y: 0, Width: 0, Height: 1},
145+ {X: 0, Y: 0, Width: 1, Height: -3},
146+ {X: 0, Y: 0, Width: ports.MaxDimension + 1, Height: 1},
147+ {X: 1 << 40, Y: 0, Width: 1, Height: 1},
148+ } {
149+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: r})
150+ require.ErrorIs(t, err, ports.ErrInvalidRegion, "%+v", r)
151+ }
152+ require.Zero(t, comp.stat(func(c *compositor) int { return c.captures }))
153+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
154+ require.NoError(t, err, "invalid regions must not end the session")
155+}
156+
157+func TestCaptureUnknownOutput(t *testing.T) {
158+ s, _ := newSource(t, compositorConfig{})
159+ _, err := s.Capture(context.Background(), ports.Target{OutputID: 99})
160+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
161+ _, err = s.Capture(context.Background(), ports.Target{OutputID: 1})
162+ require.NoError(t, err)
163+}
164+
165+func TestCaptureRejectsBadBuffers(t *testing.T) {
166+ const xrgb = 1
167+ cases := map[string]announce{
168+ "zero width": {xrgb, 0, 4, 16},
169+ "zero height": {xrgb, 4, 0, 16},
170+ "wide": {xrgb, ports.MaxDimension + 1, 1, (ports.MaxDimension + 1) * 4},
171+ "tall": {xrgb, 1, ports.MaxDimension + 1, 4},
172+ "short stride": {xrgb, 4, 4, 12},
173+ "unaligned stride": {xrgb, 4, 4, 18},
174+ "over 256 MiB": {xrgb, 16384, 16384, 16384 * 4},
175+ "huge stride": {xrgb, 4, 4, 1 << 31},
176+ "stride overflow": {xrgb, 4, 2, 0xffffffff - 3},
177+ }
178+ for name, a := range cases {
179+ t.Run(name, func(t *testing.T) {
180+ s, comp := newSource(t, compositorConfig{announce: []announce{a}})
181+ out := firstOutput(t, s)
182+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
183+ require.Error(t, err)
184+ require.Zero(t, comp.stat(func(c *compositor) int { return c.pools }), "nothing may be allocated before validation")
185+ require.Zero(t, comp.stat(func(c *compositor) int { return c.copies }))
186+ _, err = s.Outputs(context.Background())
187+ require.NoError(t, err, "a rejected announcement leaves the connection usable")
188+ })
189+ }
190+}
191+
192+func TestCaptureUnsupportedFormat(t *testing.T) {
193+ for _, format := range []uint32{2, 0x34325258, 0x34324752, 0xffffffff} { // C8, XR24-like fourcc, RG24, garbage
194+ s, comp := newSource(t, compositorConfig{announce: []announce{{format, 4, 4, 16}}})
195+ out := firstOutput(t, s)
196+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
197+ require.ErrorIs(t, err, ports.ErrUnsupportedFormat, "format %#x", format)
198+ require.Zero(t, comp.stat(func(c *compositor) int { return c.pools }))
199+ }
200+}
201+
202+func TestCapturePicksSupportedAnnouncement(t *testing.T) {
203+ s, _ := newSource(t, compositorConfig{announce: []announce{{0x34325258, 4, 4, 16}, {1, 3, 2, 12}}})
204+ out := firstOutput(t, s)
205+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
206+ require.NoError(t, err)
207+ require.Equal(t, 3, f.Width)
208+ require.Equal(t, ports.XRGB8888, f.Format)
209+}
210+
211+func TestCaptureWithoutBufferDone(t *testing.T) {
212+ s, _ := newSource(t, compositorConfig{screencopyVersion: 1})
213+ out := firstOutput(t, s)
214+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
215+ require.NoError(t, err)
216+ require.NoError(t, f.Validate())
217+}
218+
219+func TestMissingScreencopy(t *testing.T) {
220+ comp := startCompositor(t, compositorConfig{noScreencopy: true})
221+ _, err := New(context.Background(), comp.path)
222+ require.Error(t, err)
223+}
224+
225+func TestReuseAndGeometryChange(t *testing.T) {
226+ s, comp := newSource(t, compositorConfig{})
227+ out := firstOutput(t, s)
228+ target := ports.Target{OutputID: out.ID}
229+ f1, err := s.Capture(context.Background(), target)
230+ require.NoError(t, err)
231+ first := &f1.Pixels[0]
232+ for i := 2; i <= 5; i++ {
233+ f, err := s.Capture(context.Background(), target)
234+ require.NoError(t, err)
235+ require.Same(t, first, &f.Pixels[0], "storage must be reused")
236+ want := pixel(3, 1, uint32(i))
237+ require.Equal(t, want[:], f.Row(1)[12:16], "capture %d must show fresh content", i)
238+ }
239+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.pools }))
240+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.buffers }))
241+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.fdsReceived }))
242+
243+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 2, Height: 2}})
244+ require.NoError(t, err)
245+ require.Equal(t, 2, f.Width)
246+ require.Equal(t, 2, comp.stat(func(c *compositor) int { return c.pools }))
247+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.destroyedBuffers }))
248+}
249+
250+func TestNoDescriptorLeak(t *testing.T) {
251+ s, comp := newSource(t, compositorConfig{})
252+ out := firstOutput(t, s)
253+ target := ports.Target{OutputID: out.ID}
254+ _, err := s.Capture(context.Background(), target)
255+ require.NoError(t, err)
256+ steady := fdCount(t)
257+ for i := 0; i < 30; i++ {
258+ // Alternate geometry: every switch creates a pool and must not keep its descriptor.
259+ region := ports.Region{Width: 2 + i%2, Height: 2}
260+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: region})
261+ require.NoError(t, err)
262+ _, err = s.Capture(context.Background(), target)
263+ require.NoError(t, err)
264+ }
265+ require.Equal(t, 61, comp.stat(func(c *compositor) int { return c.fdsReceived }))
266+ require.Equal(t, steady, fdCount(t))
267+
268+ require.NoError(t, s.Close())
269+ require.Eventually(t, func() bool { return fdCount(t) <= steady-2 }, 2*time.Second, 5*time.Millisecond,
270+ "connection descriptors must be closed on Close")
271+}
272+
273+func TestCancelInterruptsBlockedCapture(t *testing.T) {
274+ s, comp := newSource(t, compositorConfig{hold: true})
275+ out := firstOutput(t, s)
276+ ctx, cancel := context.WithCancel(context.Background())
277+ errc := make(chan error, 1)
278+ go func() {
279+ _, err := s.Capture(ctx, ports.Target{OutputID: out.ID})
280+ errc <- err
281+ }()
282+ <-comp.copyEvent
283+ cancel()
284+ select {
285+ case err := <-errc:
286+ require.ErrorIs(t, err, context.Canceled)
287+ case <-time.After(3 * time.Second):
288+ t.Fatal("capture not interrupted")
289+ }
290+ _, err := s.Outputs(context.Background())
291+ require.ErrorIs(t, err, ports.ErrClosed, "cancellation is not recoverable")
292+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})
293+ require.ErrorIs(t, err, ports.ErrClosed)
294+ require.NoError(t, s.Close())
295+}
296+
297+func TestCloseInterruptsBlockedCapture(t *testing.T) {
298+ s, comp := newSource(t, compositorConfig{hold: true})
299+ out := firstOutput(t, s)
300+ errc := make(chan error, 1)
301+ go func() {
302+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
303+ errc <- err
304+ }()
305+ <-comp.copyEvent
306+ require.NoError(t, s.Close())
307+ select {
308+ case err := <-errc:
309+ require.ErrorIs(t, err, ports.ErrClosed)
310+ case <-time.After(3 * time.Second):
311+ t.Fatal("capture not interrupted")
312+ }
313+ require.NoError(t, s.Close())
314+ _, err := s.Outputs(context.Background())
315+ require.ErrorIs(t, err, ports.ErrClosed)
316+}
317+
318+func TestCancelledContextBeforeCall(t *testing.T) {
319+ s, _ := newSource(t, compositorConfig{})
320+ ctx, cancel := context.WithCancel(context.Background())
321+ cancel()
322+ _, err := s.Outputs(ctx)
323+ require.ErrorIs(t, err, context.Canceled)
324+ _, err = s.Outputs(context.Background())
325+ require.ErrorIs(t, err, ports.ErrClosed)
326+}
327+
328+func TestCompositorDisconnect(t *testing.T) {
329+ s, comp := newSource(t, compositorConfig{hold: true})
330+ out := firstOutput(t, s)
331+ errc := make(chan error, 1)
332+ go func() {
333+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
334+ errc <- err
335+ }()
336+ <-comp.copyEvent
337+ comp.mu.Lock()
338+ comp.conn.Close()
339+ comp.mu.Unlock()
340+ select {
341+ case err := <-errc:
342+ require.Error(t, err)
343+ case <-time.After(3 * time.Second):
344+ t.Fatal("capture not interrupted")
345+ }
346+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
347+ require.ErrorIs(t, err, ports.ErrClosed)
348+}
349+
350+func TestConstructorContextIsScopedToConstructor(t *testing.T) {
351+ comp := startCompositor(t, compositorConfig{})
352+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
353+ s, err := New(ctx, comp.path)
354+ require.NoError(t, err)
355+ defer s.Close()
356+ cancel()
357+ time.Sleep(20 * time.Millisecond)
358+ out := firstOutput(t, s)
359+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})
360+ require.NoError(t, err, "cancelling the constructor context must not close the source")
361+}
362+
363+func TestConstructorTimeout(t *testing.T) {
364+ comp := startCompositor(t, compositorConfig{silent: true})
365+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
366+ defer cancel()
367+ start := time.Now()
368+ _, err := New(ctx, comp.path)
369+ require.ErrorIs(t, err, context.DeadlineExceeded)
370+ require.Less(t, time.Since(start), 2*time.Second)
371+}
372+
373+func TestConstructorConnectFailure(t *testing.T) {
374+ _, err := New(context.Background(), t.TempDir()+"/missing")
375+ require.Error(t, err)
376+ t.Setenv("XDG_RUNTIME_DIR", "")
377+ _, err = New(context.Background(), "relative")
378+ require.Error(t, err)
379+}
380+
381+func TestConcurrentCallsAreSerialized(t *testing.T) {
382+ s, _ := newSource(t, compositorConfig{})
383+ out := firstOutput(t, s)
384+ errc := make(chan error, 8)
385+ for range 8 {
386+ go func() {
387+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
388+ if err == nil {
389+ _, err = s.Outputs(context.Background())
390+ }
391+ errc <- err
392+ }()
393+ }
394+ for range 8 {
395+ require.NoError(t, <-errc)
396+ }
397+}
398+
399+// Steady state cost of one capture over the real transport. The measurement
400+// includes the in-process protocol peer, so it is an upper bound for the
401+// Source alone; the budget is set from measured values (see the benchmark).
402+func TestCaptureAllocations(t *testing.T) {
403+ if raceEnabled {
404+ t.Skip("race instrumentation changes allocation counts")
405+ }
406+ s, _ := newSource(t, compositorConfig{outputs: []outputSpec{{name: "X", width: 64, height: 64, scale: 1, version: 4}}})
407+ out := firstOutput(t, s)
408+ ctx := context.Background()
409+ target := ports.Target{OutputID: out.ID}
410+ for range 3 { // warm pools and buffers
411+ _, err := s.Capture(ctx, target)
412+ require.NoError(t, err)
413+ }
414+ runtime.GC()
415+ avg := testing.AllocsPerRun(200, func() {
416+ if _, err := s.Capture(ctx, target); err != nil {
417+ panic(err)
418+ }
419+ })
420+ t.Logf("allocations per capture (client and in-process peer): %g", avg)
421+ const budget = 14 // measured 12 (client and in-process peer), with headroom
422+ if avg > budget {
423+ t.Fatalf("allocations per capture %g exceed budget %d", avg, budget)
424+ }
425+ cancelable, cancel := context.WithCancel(ctx)
426+ defer cancel()
427+ avgCancelable := testing.AllocsPerRun(200, func() {
428+ if _, err := s.Capture(cancelable, target); err != nil {
429+ panic(err)
430+ }
431+ })
432+ t.Logf("allocations per capture with cancellable context: %g", avgCancelable)
433+ if avgCancelable > budget {
434+ t.Fatalf("cancellable capture allocations %g exceed budget", avgCancelable)
435+ }
436+}
437+
438+func BenchmarkCapture(b *testing.B) {
439+ comp := startCompositor(b, compositorConfig{outputs: []outputSpec{{name: "BENCH", width: 1920, height: 1080, scale: 1, version: 4}}})
440+ s, err := New(context.Background(), comp.path)
441+ if err != nil {
442+ b.Fatal(err)
443+ }
444+ defer s.Close()
445+ outs, err := s.Outputs(context.Background())
446+ if err != nil || len(outs) == 0 {
447+ b.Fatal(err)
448+ }
449+ target := ports.Target{OutputID: outs[0].ID}
450+ ctx := context.Background()
451+ b.ReportAllocs()
452+ b.SetBytes(1920 * 1080 * 4)
453+ b.ResetTimer()
454+ for b.Loop() {
455+ if _, err := s.Capture(ctx, target); err != nil {
456+ b.Fatal(err)
457+ }
458+ }
459+}
460+
461+func TestCaptureWaitsForReady(t *testing.T) {
462+ s, comp := newSource(t, compositorConfig{hold: true})
463+ out := firstOutput(t, s)
464+ type result struct {
465+ f ports.Frame
466+ err error
467+ }
468+ res := make(chan result, 1)
469+ go func() {
470+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
471+ res <- result{f, err}
472+ }()
473+ <-comp.copyEvent
474+ select {
475+ case <-res:
476+ t.Fatal("capture returned before the compositor completed the copy")
477+ case <-time.After(50 * time.Millisecond):
478+ }
479+ comp.completePending()
480+ r := <-res
481+ require.NoError(t, r.err)
482+ want := pixel(1, 1, 1)
483+ require.Equal(t, want[:], r.f.Row(1)[4:8])
484+}
485+
486+// Opt-in: NEFERCAP_WAYLAND_SOCKET names the socket of a disposable headless
487+// compositor that supports wlr-screencopy. Never point it at a real session.
488+func TestHeadlessCompositor(t *testing.T) {
489+ socket := os.Getenv("NEFERCAP_WAYLAND_SOCKET")
490+ if socket == "" {
491+ t.Skip("NEFERCAP_WAYLAND_SOCKET not set")
492+ }
493+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
494+ defer cancel()
495+ s, err := New(ctx, socket)
496+ require.NoError(t, err)
497+ defer s.Close()
498+ out := firstOutput(t, s)
499+ require.NotEmpty(t, out.Name)
500+ for range 3 {
501+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
502+ require.NoError(t, err)
503+ require.NoError(t, f.Validate())
504+ }
505+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 16, Height: 8}})
506+ require.NoError(t, err)
507+ require.Equal(t, 16, f.Width)
508+ require.Equal(t, 8, f.Height)
509+}
510+
511+func TestIdleCancellationDoesNotKillSource(t *testing.T) {
512+ s, _ := newSource(t, compositorConfig{})
513+ out := firstOutput(t, s)
514+ target := ports.Target{OutputID: out.ID}
515+ ctx, cancel := context.WithCancel(context.Background())
516+ _, err := s.Capture(ctx, target)
517+ require.NoError(t, err)
518+ _, err = s.Capture(ctx, target) // same context: the watcher stays registered
519+ require.NoError(t, err)
520+ cancel()
521+ _, err = s.Capture(context.Background(), target)
522+ require.NoError(t, err, "a context cancelled between calls must not end the source")
523+ _, err = s.Outputs(ctx)
524+ require.ErrorIs(t, err, context.Canceled, "the next call given the cancelled context observes it")
525+ _, err = s.Capture(context.Background(), target)
526+ require.ErrorIs(t, err, ports.ErrClosed)
527+}
528+
529+func TestStaleContextDoesNotAffectLaterCall(t *testing.T) {
530+ s, comp := newSource(t, compositorConfig{hold: true})
531+ out := firstOutput(t, s)
532+ target := ports.Target{OutputID: out.ID}
533+ ctxA, cancelA := context.WithCancel(context.Background())
534+ ctxB, cancelB := context.WithCancel(context.Background())
535+ defer cancelB()
536+
537+ runCapture := func(ctx context.Context) chan error {
538+ errc := make(chan error, 1)
539+ go func() {
540+ _, err := s.Capture(ctx, target)
541+ errc <- err
542+ }()
543+ <-comp.copyEvent
544+ return errc
545+ }
546+ errc := runCapture(ctxA)
547+ comp.completePending()
548+ require.NoError(t, <-errc)
549+
550+ errc = runCapture(ctxB) // blocked under B while A's old registration is replaced
551+ cancelA()
552+ select {
553+ case err := <-errc:
554+ t.Fatalf("cancelling a previous call's context ended a later call: %v", err)
555+ case <-time.After(50 * time.Millisecond):
556+ }
557+ comp.completePending()
558+ require.NoError(t, <-errc)
559+
560+ errc = runCapture(ctxB)
561+ cancelB()
562+ select {
563+ case err := <-errc:
564+ require.ErrorIs(t, err, context.Canceled)
565+ case <-time.After(3 * time.Second):
566+ t.Fatal("blocked capture not interrupted")
567+ }
568+ s.opMu.Lock()
569+ released := s.buf.data == nil && s.watchStop == nil
570+ s.opMu.Unlock()
571+ require.True(t, released, "terminal source must release its mapping and watcher")
572+ _, err := s.Outputs(context.Background())
573+ require.ErrorIs(t, err, ports.ErrClosed)
574+}
575+
576+func TestContextSwitchRace(t *testing.T) {
577+ s, _ := newSource(t, compositorConfig{})
578+ out := firstOutput(t, s)
579+ target := ports.Target{OutputID: out.ID}
580+ for i := 0; i < 200; i++ {
581+ ctx, cancel := context.WithCancel(context.Background())
582+ _, err := s.Capture(ctx, target)
583+ require.NoError(t, err)
584+ go cancel() // races with the next call, which uses another context
585+ _, err = s.Capture(context.Background(), target)
586+ require.NoError(t, err)
587+ cancel()
588+ }
589+}
590+
591+func TestNoShmOffer(t *testing.T) {
592+ s, comp := newSource(t, compositorConfig{noShmOffer: true})
593+ out := firstOutput(t, s)
594+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
595+ require.ErrorIs(t, err, ports.ErrUnsupportedFormat)
596+ require.ErrorContains(t, err, "no wl_shm buffer")
597+ require.Zero(t, comp.stat(func(c *compositor) int { return c.pools }))
598+ _, err = s.Outputs(context.Background())
599+ require.NoError(t, err)
600+}
601+
602+func TestBufferDoneOnlyAfterShmOffer(t *testing.T) {
603+ // The default peer sends buffer then buffer_done: capture waits for the latter.
604+ s, _ := newSource(t, compositorConfig{})
605+ out := firstOutput(t, s)
606+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
607+ require.NoError(t, err)
608+}
609+
610+func TestMalformedOutputModeAndScale(t *testing.T) {
611+ s, _ := newSource(t, compositorConfig{outputs: []outputSpec{
612+ {name: "BAD", width: 0xffffffff, height: 1 << 30, scale: -4, version: 4},
613+ {name: "HUGE", width: 100, height: 50, scale: 1000, version: 4},
614+ }})
615+ outs, err := s.Outputs(context.Background())
616+ require.NoError(t, err)
617+ require.Equal(t, []ports.Output{
618+ {ID: 1, Name: "BAD", Scale: 1},
619+ {ID: 2, Name: "HUGE", Width: 100, Height: 50, Scale: maxOutputScale},
620+ }, outs)
621+}
622+
623+func TestDuplicateGlobalIgnored(t *testing.T) {
624+ s, _ := newSource(t, compositorConfig{duplicateGlobal: true})
625+ outs, err := s.Outputs(context.Background())
626+ require.NoError(t, err)
627+ require.Len(t, outs, 1)
628+ require.Equal(t, 1, s.bound)
629+}
630+
631+func TestReadyWithoutCopyIsTerminal(t *testing.T) {
632+ s, _ := newSource(t, compositorConfig{readyEarly: true})
633+ out := firstOutput(t, s)
634+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
635+ require.Error(t, err)
636+ _, err = s.Outputs(context.Background())
637+ require.ErrorIs(t, err, ports.ErrClosed)
638+}
639+
640+func TestCompositorFailureIsRecoverable(t *testing.T) {
641+ t.Run("before copy", func(t *testing.T) {
642+ s, comp := newSource(t, compositorConfig{failCapture: true})
643+ out := firstOutput(t, s)
644+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
645+ require.Error(t, err)
646+ comp.mu.Lock()
647+ comp.cfg.failCapture = false
648+ comp.mu.Unlock()
649+ _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID})
650+ require.NoError(t, err)
651+ })
652+ t.Run("after copy", func(t *testing.T) {
653+ s, _ := newSource(t, compositorConfig{failCopies: 1})
654+ out := firstOutput(t, s)
655+ _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
656+ require.Error(t, err)
657+ require.NotErrorIs(t, err, ports.ErrClosed)
658+ f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
659+ require.NoError(t, err)
660+ require.NoError(t, f.Validate())
661+ })
662+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close