v0.2.0 13c6a90

imagecapture_test.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"image"
6	"strings"
7	"sync/atomic"
8	"testing"
9	"time"
10
11	source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
12	ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
13	extws "github.com/bnema/go-wayland-bindings/server/extworkspace"
14	"github.com/bnema/go-wayland-bindings/server/wayland"
15	"github.com/bnema/go-wayland-bindings/server/wlrlayershell"
16	wlr "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
17	"github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
18	"github.com/bnema/neferwl/internal/logging"
19	"github.com/bnema/neferwl/internal/ports"
20	"github.com/bnema/purego-libwayland/server"
21	"github.com/bnema/wlturbo"
22	"golang.org/x/sys/unix"
23)
24
25// sessionMsg is one decoded event of the neferwl extension.
26type sessionMsg struct {
27	op      uint16
28	token   string
29	numbers []int64
30}
31
32// attachEvents decodes the events of a neferwl_capture_layer_v1.
33type attachEvents struct {
34	wlturbo.BaseProxy
35	msgs chan sessionMsg
36}
37
38func (p *attachEvents) Dispatch(e *wlturbo.Event) {
39	m := sessionMsg{op: e.Opcode}
40	if uint32(e.Opcode) != imagecapture.NeferwlCaptureLayerV1EventAttached {
41		m.numbers = append(m.numbers, int64(e.Uint32()))
42	}
43	p.msgs <- m
44}
45
46// exclusionEvents decodes the events of a neferwl_capture_exclusion_v1.
47type exclusionEvents struct {
48	wlturbo.BaseProxy
49	msgs chan sessionMsg
50}
51
52func (p *exclusionEvents) Dispatch(e *wlturbo.Event) {
53	m := sessionMsg{op: e.Opcode}
54	switch uint32(e.Opcode) {
55	case imagecapture.NeferwlCaptureExclusionV1EventToken:
56		m.token = e.String()
57	case imagecapture.NeferwlCaptureExclusionV1EventFailed:
58		m.numbers = append(m.numbers, int64(e.Uint32()))
59	}
60	p.msgs <- m
61}
62
63// captureHarness is a server with one 4x4 output and every capture channel.
64type captureHarness struct {
65	s          *Server
66	dir        string
67	events     chan ports.ClientEvent
68	commands   chan ports.ClientCommand
69	workspaces chan ports.Workspaces
70	captures   chan ports.CaptureRequest
71	captured   chan ports.CaptureDone
72}
73
74var captureOutput = ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}
75
76func newCaptureHarness(t *testing.T) *captureHarness {
77	t.Helper()
78	return newCaptureHarnessWith(t, nil)
79}
80
81// newCaptureHarnessWith lets a test adjust the options and channels (for
82// instance to wire a session-lock gate) before the server starts.
83func newCaptureHarnessWith(t *testing.T, tune func(*Options, *Channels)) *captureHarness {
84	t.Helper()
85	h := &captureHarness{
86		dir:        t.TempDir(),
87		events:     make(chan ports.ClientEvent, 64),
88		commands:   make(chan ports.ClientCommand, 16),
89		workspaces: make(chan ports.Workspaces, 4),
90		captures:   make(chan ports.CaptureRequest, 8),
91		captured:   make(chan ports.CaptureDone, 8),
92	}
93	opts := Options{RuntimeDir: h.dir, Outputs: ports.Layout{captureOutput}, CaptureAllow: allowStore(t, "*\n")}
94	ch := Channels{Events: h.events, Commands: h.commands, Workspaces: h.workspaces, Captures: h.captures, Captured: h.captured}
95	if tune != nil {
96		tune(&opts, &ch)
97	}
98	s, err := New(opts, ch, logging.For(context.Background(), "wayland"))
99	if err != nil {
100		t.Fatal(err)
101	}
102	h.s = s
103	ctx, cancel := context.WithCancel(context.Background())
104	done := make(chan error, 1)
105	go func() { done <- s.Run(ctx) }()
106	t.Cleanup(func() {
107		cancel()
108		if err := <-done; err != nil {
109			t.Errorf("Run: %v", err)
110		}
111		for len(h.captures) > 0 {
112			_ = (<-h.captures).Dst.File.Close()
113		}
114	})
115	return h
116}
117
118// cclient is a client with every capture global bound.
119type cclient struct {
120	c       *wlturbo.Display
121	output  uint32
122	outSrc  uint32 // ext_output_image_capture_source_manager_v1
123	extra   uint32 // neferwl_image_capture_source_manager_v1
124	copyMgr uint32 // ext_image_copy_capture_manager_v1
125	excl    uint32 // neferwl_capture_exclusion_manager_v1
126}
127
128func (h *captureHarness) client(t *testing.T) *cclient {
129	t.Helper()
130	c := protocolClient(t, h.s, h.dir)
131	out := bindVersion(t, c, "wl_output", 4)
132	registerProtocol(t, c, out)
133	return &cclient{c: c, output: out,
134		outSrc:  bindProtocol(t, c, "ext_output_image_capture_source_manager_v1"),
135		extra:   bindProtocol(t, c, "neferwl_image_capture_source_manager_v1"),
136		copyMgr: bindProtocol(t, c, "ext_image_copy_capture_manager_v1"),
137		excl:    bindProtocol(t, c, "neferwl_capture_exclusion_manager_v1"),
138	}
139}
140
141func (cc *cclient) roundtrip(t *testing.T) {
142	t.Helper()
143	if err := cc.c.Roundtrip(); err != nil {
144		t.Fatal(err)
145	}
146}
147
148// outputSource, regionSource and workspaceSource make a capture source.
149func (cc *cclient) outputSource(t *testing.T) uint32 {
150	t.Helper()
151	src := cc.c.AllocateID()
152	registerProtocol(t, cc.c, src)
153	requestProtocol(t, cc.c, cc.outSrc, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, cc.output)
154	return src
155}
156
157func (cc *cclient) regionSource(t *testing.T, x, y, w, h int32) uint32 {
158	t.Helper()
159	src := cc.c.AllocateID()
160	registerProtocol(t, cc.c, src)
161	requestProtocol(t, cc.c, cc.extra, imagecapture.NeferwlImageCaptureSourceManagerV1RequestCreateOutputRegionSource, src, cc.output, x, y, w, h)
162	return src
163}
164
165func (cc *cclient) workspaceSource(t *testing.T, handle uint32) uint32 {
166	t.Helper()
167	src := cc.c.AllocateID()
168	registerProtocol(t, cc.c, src)
169	requestProtocol(t, cc.c, cc.extra, imagecapture.NeferwlImageCaptureSourceManagerV1RequestCreateWorkspaceSource, src, handle)
170	return src
171}
172
173// session opens an ext-image-copy-capture session on a source and returns its
174// object and the decoded events (see captureDetails).
175func (cc *cclient) session(t *testing.T, src uint32) (uint32, chan []uint32) {
176	t.Helper()
177	id := cc.c.AllocateID()
178	p := &captureDetails{events: make(chan []uint32, 32)}
179	p.SetID(id)
180	registerWireProxy(cc.c, p)
181	requestProtocol(t, cc.c, cc.copyMgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, id, src, uint32(0))
182	cc.roundtrip(t)
183	return id, p.events
184}
185
186// Session event opcodes as captureDetails reports them.
187const (
188	evBufferSize = uint32(ext.ExtImageCopyCaptureSessionV1EventBufferSize)
189	evDone       = uint32(ext.ExtImageCopyCaptureSessionV1EventDone)
190	evStopped    = uint32(ext.ExtImageCopyCaptureSessionV1EventStopped)
191)
192
193// constraints reads one complete batch (buffer_size, two formats, done) and
194// returns the size.
195func constraints(t *testing.T, cc *cclient, ev chan []uint32) (w, h uint32) {
196	t.Helper()
197	got := nextSession(t, cc, ev)
198	if got[0] != evBufferSize || len(got) != 3 {
199		t.Fatalf("expected buffer_size, got %v", got)
200	}
201	w, h = got[1], got[2]
202	for _, want := range []uint32{uint32(ext.ExtImageCopyCaptureSessionV1EventShmFormat), uint32(ext.ExtImageCopyCaptureSessionV1EventShmFormat), evDone} {
203		if g := nextSession(t, cc, ev); g[0] != want {
204			t.Fatalf("batch event %v, want opcode %d", g, want)
205		}
206	}
207	return w, h
208}
209
210// nextSession waits for the next decoded session event, dispatching the client.
211func nextSession(t *testing.T, cc *cclient, ev chan []uint32) []uint32 {
212	t.Helper()
213	deadline := time.Now().Add(2 * time.Second)
214	for time.Now().Before(deadline) {
215		select {
216		case v := <-ev:
217			return v
218		default:
219		}
220		cc.roundtrip(t)
221		time.Sleep(time.Millisecond)
222	}
223	t.Fatal("timed out waiting for a session event")
224	return nil
225}
226
227func noSession(t *testing.T, cc *cclient, ev chan []uint32) {
228	t.Helper()
229	cc.roundtrip(t)
230	select {
231	case v := <-ev:
232		t.Fatalf("unexpected session event %v", v)
233	default:
234	}
235}
236
237// settle waits until the server applied the commands sent so far and the
238// client received what followed.
239func (h *captureHarness) settle(t *testing.T, cc *cclient) {
240	t.Helper()
241	// The command loop takes commands off the channel before it applies
242	// them on the display: an empty channel does not mean applied. Commands
243	// apply in order, so a marker (SlotsPending, a plain flag) seen on the
244	// display means every command sent before it was applied.
245	var was bool
246	if !h.s.display.Do(func() { was = h.s.slotsPending }) {
247		t.Fatal("display stopped")
248	}
249	h.applied(t, !was)
250	h.applied(t, was)
251	cc.roundtrip(t)
252}
253
254// applied sends SlotsPending{pending} and waits until the display applied it.
255func (h *captureHarness) applied(t *testing.T, pending bool) {
256	t.Helper()
257	h.commands <- ports.SlotsPending{Pending: pending}
258	deadline := time.Now().Add(2 * time.Second)
259	for {
260		var now bool
261		if !h.s.display.Do(func() { now = h.s.slotsPending }) {
262			t.Fatal("display stopped")
263		}
264		if now == pending {
265			return
266		}
267		if time.Now().After(deadline) {
268			t.Fatal("commands not applied")
269		}
270		time.Sleep(time.Millisecond)
271	}
272}
273
274func (h *captureHarness) send(cmd ports.ClientCommand) { h.commands <- cmd }
275
276// captureShm makes a w x h XRGB8888 wl_buffer.
277func captureShm(t *testing.T, c *wlturbo.Display, w, h int32) (buf uint32, fd int) {
278	t.Helper()
279	shm := bindProtocol(t, c, "wl_shm")
280	registerProtocol(t, c, shm)
281	fd, err := unix.MemfdCreate("ext-capture", 0)
282	if err != nil {
283		t.Fatal(err)
284	}
285	size := w * h * 4
286	if err := unix.Ftruncate(fd, int64(size)); err != nil {
287		t.Fatal(err)
288	}
289	pool := c.AllocateID()
290	buf = c.AllocateID()
291	registerProtocol(t, c, pool)
292	registerProtocol(t, c, buf)
293	if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil {
294		t.Fatal(err)
295	}
296	requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), w, h, w*4, uint32(wayland.ShmFormatXrgb8888))
297	t.Cleanup(func() { _ = unix.Close(fd) })
298	return buf, fd
299}
300
301// extFrame asks one frame of a session into a fresh w x h buffer and returns
302// its events; the frame object is destroyed so the next one may follow.
303func (cc *cclient) extFrame(t *testing.T, session uint32, w, h int32) *captureEvents {
304	t.Helper()
305	buf, _ := captureShm(t, cc.c, w, h)
306	frame := cc.c.AllocateID()
307	ev := &captureEvents{events: make(chan uint16, 16)}
308	ev.SetID(frame)
309	registerWireProxy(cc.c, ev)
310	requestProtocol(t, cc.c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
311	requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
312	requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
313	cc.roundtrip(t)
314	requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestDestroy)
315	cc.roundtrip(t)
316	return ev
317}
318
319// wlrFrame asks a wlr-screencopy capture of the whole output into a buffer.
320func (cc *cclient) wlrFrame(t *testing.T) *captureEvents {
321	t.Helper()
322	buf, _ := captureShm(t, cc.c, 4, 4)
323	mgr := bindVersion(t, cc.c, "zwlr_screencopy_manager_v1", 3)
324	frame := cc.c.AllocateID()
325	ev := &captureEvents{events: make(chan uint16, 16)}
326	ev.SetID(frame)
327	registerWireProxy(cc.c, ev)
328	requestProtocol(t, cc.c, mgr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), cc.output)
329	requestProtocol(t, cc.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
330	cc.roundtrip(t)
331	return ev
332}
333
334func receiveCapture(t *testing.T, ch chan ports.CaptureRequest) ports.CaptureRequest {
335	t.Helper()
336	select {
337	case r := <-ch:
338		_ = r.Dst.File.Close()
339		return r
340	case <-time.After(2 * time.Second):
341		t.Fatal("no capture request")
342		return ports.CaptureRequest{}
343	}
344}
345
346func noCapture(t *testing.T, ch chan ports.CaptureRequest) {
347	t.Helper()
348	select {
349	case r := <-ch:
350		_ = r.Dst.File.Close()
351		t.Fatalf("a capture reached the renderer: %+v", r)
352	default:
353	}
354}
355
356// eventsInclude drains the frame events received so far and reports op among them.
357func eventsInclude(ev *captureEvents, op uint16) bool {
358	found := false
359	for len(ev.events) > 0 {
360		if <-ev.events == op {
361			found = true
362		}
363	}
364	return found
365}
366
367const (
368	frameReady  = uint16(ext.ExtImageCopyCaptureFrameV1EventReady)
369	frameFailed = uint16(ext.ExtImageCopyCaptureFrameV1EventFailed)
370)
371
372func (h *captureHarness) open(t *testing.T) ports.CaptureSessionOpen {
373	t.Helper()
374	return nextEvent[ports.CaptureSessionOpen](t, h.events)
375}
376
377// active is the state core sends for a live session on the whole output.
378func active(id uint64) ports.CaptureSessionState {
379	return ports.CaptureSessionState{ID: id, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true}
380}
381
382func TestTrustedPeer(t *testing.T) {
383	for _, tc := range []struct {
384		name string
385		peer server.Credentials
386		err  error
387		want bool
388	}{
389		{"same user", server.Credentials{UID: 1000}, nil, true},
390		{"other user", server.Credentials{UID: 1001}, nil, false},
391		{"root is not the user", server.Credentials{UID: 0}, nil, false},
392		{"overflow id", server.Credentials{UID: 65534}, nil, false},
393		{"unreadable", server.Credentials{UID: 1000}, context.Canceled, false},
394	} {
395		if got := trustedPeer(1000, tc.peer, tc.err); got != tc.want {
396			t.Errorf("%s: %v", tc.name, got)
397		}
398	}
399}
400
401// A session on an output source registers with core and gets the output size.
402func TestOutputSessionRegistersAndClose(t *testing.T) {
403	h := newCaptureHarness(t)
404	cc := h.client(t)
405	session, ev := cc.session(t, cc.outputSource(t))
406	if w, hh := constraints(t, cc, ev); w != 4 || hh != 4 {
407		t.Fatalf("size %dx%d", w, hh)
408	}
409	open := h.open(t)
410	if open.ID == 0 || open.Output != "HEADLESS-1" || open.Workspace != 0 || open.Region != (ports.Rect{}) {
411		t.Fatalf("open %+v", open)
412	}
413	requestProtocol(t, cc.c, session, ext.ExtImageCopyCaptureSessionV1RequestDestroy)
414	cc.roundtrip(t)
415	if c := nextEvent[ports.CaptureSessionClose](t, h.events); c.ID != open.ID {
416		t.Fatalf("close %+v", c)
417	}
418}
419
420// A region source is clipped to the output, sized in physical pixels, and its
421// buffer size is sent again when the scale changes.
422func TestRegionSourceConstraintsFollowScale(t *testing.T) {
423	h := newCaptureHarness(t)
424	cc := h.client(t)
425	session, ev := cc.session(t, cc.regionSource(t, 1, 1, 2, 2))
426	if w, hh := constraints(t, cc, ev); w != 2 || hh != 2 {
427		t.Fatalf("size %dx%d", w, hh)
428	}
429	open := h.open(t)
430	if open.Output != "HEADLESS-1" || open.Region != (ports.Rect{X: 1, Y: 1, W: 2, H: 2}) {
431		t.Fatalf("open %+v", open)
432	}
433	// Clipped: a rectangle past the output edge.
434	_, ev2 := cc.session(t, cc.regionSource(t, 3, 3, 10, 10))
435	if w, hh := constraints(t, cc, ev2); w != 1 || hh != 1 {
436		t.Fatalf("clipped size %dx%d", w, hh)
437	}
438	h.open(t)
439	// Doubling the scale of a bigger mode doubles the buffer.
440	bigger := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 8, Height: 8}, Width: 4, Height: 4, Scale: 2}
441	applyOutputs(t, h.s, h.commands, ports.SetOutputs{Outputs: ports.Layout{bigger}})
442	if w, hh := constraints(t, cc, ev); w != 4 || hh != 4 {
443		t.Fatalf("size after the scale change %dx%d", w, hh)
444	}
445	noSession(t, cc, ev) // one batch only
446	_ = session
447	// The frame asked with the new size is served for the new region.
448	r := func() ports.CaptureRequest {
449		cc.extFrame(t, session, 4, 4)
450		return receiveCapture(t, h.captures)
451	}()
452	if r.Region.Dx() != 4 || r.Region.Dy() != 4 || r.Workspace != 0 || r.Exclude {
453		t.Fatalf("request %+v", r)
454	}
455}
456
457// A frame whose buffer does not fit the geometry computed now, while the last
458// constraints the client got are stale (the refresh has not run), fails with
459// buffer_constraints after a new constraints batch: the client can retry.
460func TestStaleConstraintsAreResentBeforeBufferConstraintsFailure(t *testing.T) {
461	h := newCaptureHarness(t)
462	cc := h.client(t)
463	session, ev := cc.session(t, cc.outputSource(t))
464	constraints(t, cc, ev)
465	h.open(t)
466	// The client's last batch said 1x1 while the output is 4x4 now.
467	if !h.s.display.Do(func() {
468		for c := range h.s.captureSessions {
469			c.sent = image.Pt(1, 1)
470		}
471	}) {
472		t.Fatal("display stopped")
473	}
474	frame := cc.extFrame(t, session, 1, 1)
475	if !eventsInclude(frame, frameFailed) {
476		t.Fatal("a buffer of the stale size was served")
477	}
478	noCapture(t, h.captures)
479	if w, hh := constraints(t, cc, ev); w != 4 || hh != 4 {
480		t.Fatalf("resent constraints %dx%d", w, hh)
481	}
482	// Up to date: a wrong buffer fails without another batch.
483	frame = cc.extFrame(t, session, 1, 1)
484	if !eventsInclude(frame, frameFailed) {
485		t.Fatal("a buffer of the wrong size was served")
486	}
487	noSession(t, cc, ev)
488}
489
490func TestRegionSourceInvalidSize(t *testing.T) {
491	for _, size := range [][2]int32{{0, 4}, {4, 0}, {-1, 4}} {
492		h := newCaptureHarness(t)
493		cc := h.client(t)
494		cc.regionSource(t, 0, 0, size[0], size[1])
495		expectProtocolError(t, cc.c, cc.extra, uint32(imagecapture.NeferwlImageCaptureSourceManagerV1ErrorInvalidRegion))
496	}
497}
498
499// A region with nothing left on the output stops at once and is not
500// registered with core; so does a session on an output that is gone.
501func TestRegionAndOutputSourcesThatAreEmptyStop(t *testing.T) {
502	h := newCaptureHarness(t)
503	cc := h.client(t)
504	_, ev := cc.session(t, cc.regionSource(t, 100, 100, 5, 5))
505	if g := nextSession(t, cc, ev); g[0] != evStopped {
506		t.Fatalf("event %v", g)
507	}
508	select {
509	case e := <-h.events:
510		t.Fatalf("event for a stopped session: %+v", e)
511	default:
512	}
513}
514
515// Removing the output stops its sessions at once and tells core.
516func TestOutputRemovalStopsRegionAndOutputSessions(t *testing.T) {
517	h := newCaptureHarness(t)
518	cc := h.client(t)
519	_, ev1 := cc.session(t, cc.outputSource(t))
520	_, ev2 := cc.session(t, cc.regionSource(t, 0, 0, 2, 2))
521	constraints(t, cc, ev1)
522	constraints(t, cc, ev2)
523	a, b := h.open(t), h.open(t)
524	applyOutputs(t, h.s, h.commands, ports.SetOutputs{})
525	for _, ev := range []chan []uint32{ev1, ev2} {
526		if g := nextSession(t, cc, ev); g[0] != evStopped {
527			t.Fatalf("event %v", g)
528		}
529	}
530	closed := map[uint64]bool{}
531	for len(closed) < 2 {
532		closed[nextEvent[ports.CaptureSessionClose](t, h.events).ID] = true
533	}
534	if !closed[a.ID] || !closed[b.ID] {
535		t.Fatalf("closed %v", closed)
536	}
537}
538
539// workspaceClient binds ext_workspace_manager_v1 on cc and returns the handle
540// of each workspace announced, by order.
541func (h *captureHarness) handles(t *testing.T, cc *cclient, want int) []uint32 {
542	t.Helper()
543	mgr := bindProtocol(t, cc.c, "ext_workspace_manager_v1")
544	p := &workspaceEvents{client: cc.c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}}
545	p.SetID(mgr)
546	registerWireProxy(cc.c, p)
547	var out []uint32
548	for i := 0; i < 16 && len(out) < want; i++ {
549		cc.roundtrip(t)
550		for len(p.events) > 0 {
551			if v := <-p.events; v[0] == uint32(extws.ExtWorkspaceManagerV1EventWorkspace) {
552				out = append(out, v[1])
553			}
554		}
555	}
556	if len(out) != want {
557		t.Fatalf("%d workspace handles, want %d", len(out), want)
558	}
559	return out
560}
561
562func (h *captureHarness) snapshot(t *testing.T, ws ...ports.WorkspaceInfo) {
563	t.Helper()
564	h.workspaces <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: ws}}}
565	deadline := time.Now().Add(2 * time.Second)
566	for time.Now().Before(deadline) {
567		got := 0
568		h.s.display.Do(func() {
569			for _, o := range h.s.workspaceSnapshot.Outputs {
570				got += len(o.Workspaces)
571			}
572		})
573		if got == len(ws) {
574			return
575		}
576		time.Sleep(time.Millisecond)
577	}
578	t.Fatal("snapshot not applied")
579}
580
581// A workspace source registers the workspace ID, sizes buffers from the
582// frame (off screen: the whole frame at the output scale), sends new
583// constraints when core moves or resizes it, and stops at once when the
584// handle is removed.
585func TestWorkspaceSourceSessionLifecycle(t *testing.T) {
586	h := newCaptureHarness(t)
587	h.snapshot(t,
588		ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}},
589		ports.WorkspaceInfo{ID: 43, Name: "2", Hidden: true, Index: 1, Frame: ports.Rect{W: 3, H: 2}},
590	)
591	cc := h.client(t)
592	handles := h.handles(t, cc, 2)
593	// On screen: its frame on the output.
594	_, evOn := cc.session(t, cc.workspaceSource(t, handles[0]))
595	if w, hh := constraints(t, cc, evOn); w != 4 || hh != 4 {
596		t.Fatalf("on-screen size %dx%d", w, hh)
597	}
598	if o := h.open(t); o.Workspace != 42 || o.Output != "" {
599		t.Fatalf("open %+v", o)
600	}
601	// Off screen: the whole frame.
602	sess, ev := cc.session(t, cc.workspaceSource(t, handles[1]))
603	if w, hh := constraints(t, cc, ev); w != 3 || hh != 2 {
604		t.Fatalf("off-screen size %dx%d", w, hh)
605	}
606	open := h.open(t)
607	if open.Workspace != 43 {
608		t.Fatalf("open %+v", open)
609	}
610	// Core resizes the frame: new constraints, once.
611	h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 2, H: 2}, Workspace: 43, Hidden: true, Active: true})
612	if w, hh := constraints(t, cc, ev); w != 2 || hh != 2 {
613		t.Fatalf("resized %dx%d", w, hh)
614	}
615	h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 2, H: 2}, Workspace: 43, Hidden: true, Active: true, Revision: 0})
616	h.settle(t, cc)
617	noSession(t, cc, ev)
618	// A frame asks for the off-screen workspace, whole.
619	cc.extFrame(t, sess, 2, 2)
620	r := receiveCapture(t, h.captures)
621	if r.Workspace != 43 || !r.OffScreen || r.Session != open.ID || r.Region.Dx() != 2 || r.Region.Dy() != 2 {
622		t.Fatalf("request %+v", r)
623	}
624	// Removing workspace 43 stops its session at once; the other lives on.
625	h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}})
626	if g := nextSession(t, cc, ev); g[0] != evStopped {
627		t.Fatalf("event %v", g)
628	}
629	if c := nextEvent[ports.CaptureSessionClose](t, h.events); c.ID != open.ID {
630		t.Fatalf("close %+v", c)
631	}
632	noSession(t, cc, evOn)
633}
634
635// A handle that is already removed gives a source whose sessions stop at once.
636func TestWorkspaceSourceOfRemovedHandleStops(t *testing.T) {
637	h := newCaptureHarness(t)
638	h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}})
639	cc := h.client(t)
640	handles := h.handles(t, cc, 1)
641	h.snapshot(t)
642	cc.roundtrip(t)
643	_, ev := cc.session(t, cc.workspaceSource(t, handles[0]))
644	if g := nextSession(t, cc, ev); g[0] != evStopped {
645		t.Fatalf("event %v", g)
646	}
647}
648
649// The workspace ID survives a configured-name change: same handle, same
650// decimal id, so a source made before stays valid.
651func TestWorkspaceSourceSurvivesConfiguredRename(t *testing.T) {
652	h := newCaptureHarness(t)
653	h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}})
654	cc := h.client(t)
655	handles := h.handles(t, cc, 1)
656	_, ev := cc.session(t, cc.workspaceSource(t, handles[0]))
657	constraints(t, cc, ev)
658	h.open(t)
659	h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "dev", Configured: "dev", Active: true, Frame: ports.Rect{W: 4, H: 4}})
660	h.settle(t, cc)
661	noSession(t, cc, ev)
662	select {
663	case e := <-h.events:
664		t.Fatalf("event %+v after a rename", e)
665	default:
666	}
667}
668
669// frameEvents decodes the events of a neferwl_workspace_frame_v1.
670type frameEvents struct {
671	wlturbo.BaseProxy
672	rects chan [4]int32
673}
674
675func (p *frameEvents) Dispatch(e *wlturbo.Event) {
676	p.rects <- [4]int32{e.Int32(), e.Int32(), e.Int32(), e.Int32()}
677}
678
679// workspaceFrame asks get_workspace_frame for a workspace handle.
680func (cc *cclient) workspaceFrame(t *testing.T, handle uint32) (uint32, chan [4]int32) {
681	t.Helper()
682	id := cc.c.AllocateID()
683	ev := &frameEvents{rects: make(chan [4]int32, 8)}
684	ev.SetID(id)
685	registerWireProxy(cc.c, ev)
686	requestProtocol(t, cc.c, cc.extra, imagecapture.NeferwlImageCaptureSourceManagerV1RequestGetWorkspaceFrame, id, handle)
687	cc.roundtrip(t)
688	return id, ev.rects
689}
690
691func wantFrame(t *testing.T, ch chan [4]int32, want [4]int32) {
692	t.Helper()
693	select {
694	case got := <-ch:
695		if got != want {
696			t.Fatalf("frame %v, want %v", got, want)
697		}
698	default:
699		t.Fatalf("no frame, want %v", want)
700	}
701}
702
703func noFrame(t *testing.T, ch chan [4]int32) {
704	t.Helper()
705	select {
706	case got := <-ch:
707		t.Fatalf("unexpected frame %v", got)
708	default:
709	}
710}
711
712// A workspace frame object reports the frame when it is created and each time
713// it changes, for every workspace, whether or not it is on screen; it reports
714// nothing for a removed handle and stops after a rename replaced the handle.
715func TestWorkspaceFrameEvents(t *testing.T) {
716	h := newCaptureHarness(t)
717	h.snapshot(t,
718		ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{X: 1, Y: 0, W: 2, H: 4}},
719		ports.WorkspaceInfo{ID: 43, Name: "2", Hidden: true, Index: 1, Frame: ports.Rect{W: 3, H: 2}},
720	)
721	cc := h.client(t)
722	handles := h.handles(t, cc, 2)
723	_, on := cc.workspaceFrame(t, handles[0])
724	wantFrame(t, on, [4]int32{1, 0, 2, 4})
725	_, off := cc.workspaceFrame(t, handles[1])
726	wantFrame(t, off, [4]int32{0, 0, 3, 2})
727	// Unchanged snapshot: nothing. A change: once, to the right object.
728	h.snapshot(t,
729		ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{X: 1, Y: 0, W: 2, H: 4}},
730		ports.WorkspaceInfo{ID: 43, Name: "2", Hidden: true, Index: 1, Frame: ports.Rect{X: 1, Y: 1, W: 2, H: 2}},
731	)
732	h.settle(t, cc)
733	noFrame(t, on)
734	wantFrame(t, off, [4]int32{1, 1, 2, 2})
735	noFrame(t, off)
736	// Removal sends nothing; a handle removed before the request sends nothing.
737	h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{X: 1, Y: 0, W: 2, H: 4}})
738	h.settle(t, cc)
739	noFrame(t, off)
740	_, gone := cc.workspaceFrame(t, handles[1])
741	noFrame(t, gone)
742	// A configured rename replaces the handle: the old object goes quiet.
743	h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "dev", Configured: "dev", Active: true, Frame: ports.Rect{W: 4, H: 4}})
744	h.settle(t, cc)
745	noFrame(t, on)
746}
747
748// While the session is locked a frame object sends nothing, whether it is
749// created before the lock or during it; the unlock sends the current frame
750// once, and only if it changed.
751func TestWorkspaceFrameSessionLock(t *testing.T) {
752	// unlock leaves protection the way the session-lock unlock does.
753	unlock := func(t *testing.T, h *captureHarness, state *atomic.Pointer[ports.SecurityState]) {
754		t.Helper()
755		protect(t, h.s, state, 2, false)
756		if !h.s.display.Do(func() { h.s.updateWorkspaceManagers(h.s.workspaceSnapshot) }) {
757			t.Fatal("display stopped")
758		}
759	}
760	info := func(r ports.Rect) ports.WorkspaceInfo {
761		return ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: r}
762	}
763	t.Run("change while locked", func(t *testing.T) {
764		h := newCaptureHarness(t)
765		h.snapshot(t, info(ports.Rect{W: 2, H: 2}))
766		cc := h.client(t)
767		handles := h.handles(t, cc, 1)
768		_, ev := cc.workspaceFrame(t, handles[0])
769		wantFrame(t, ev, [4]int32{0, 0, 2, 2})
770		state := installSecurity(t, h.s)
771		protect(t, h.s, state, 1, true)
772		h.snapshot(t, info(ports.Rect{X: 1, Y: 1, W: 3, H: 3}))
773		h.settle(t, cc)
774		noFrame(t, ev)
775		unlock(t, h, state)
776		cc.roundtrip(t)
777		wantFrame(t, ev, [4]int32{1, 1, 3, 3})
778		noFrame(t, ev)
779	})
780	t.Run("no change while locked", func(t *testing.T) {
781		h := newCaptureHarness(t)
782		h.snapshot(t, info(ports.Rect{W: 2, H: 2}))
783		cc := h.client(t)
784		handles := h.handles(t, cc, 1)
785		_, ev := cc.workspaceFrame(t, handles[0])
786		wantFrame(t, ev, [4]int32{0, 0, 2, 2})
787		state := installSecurity(t, h.s)
788		protect(t, h.s, state, 1, true)
789		h.snapshot(t, info(ports.Rect{X: 1, Y: 1, W: 3, H: 3}))
790		h.snapshot(t, info(ports.Rect{W: 2, H: 2}))
791		h.settle(t, cc)
792		unlock(t, h, state)
793		cc.roundtrip(t)
794		noFrame(t, ev)
795	})
796	t.Run("get while locked", func(t *testing.T) {
797		h := newCaptureHarness(t)
798		h.snapshot(t, info(ports.Rect{W: 2, H: 2}))
799		cc := h.client(t)
800		handles := h.handles(t, cc, 1)
801		state := installSecurity(t, h.s)
802		protect(t, h.s, state, 1, true)
803		_, ev := cc.workspaceFrame(t, handles[0])
804		h.settle(t, cc)
805		noFrame(t, ev)
806		unlock(t, h, state)
807		cc.roundtrip(t)
808		wantFrame(t, ev, [4]int32{0, 0, 2, 2})
809		noFrame(t, ev)
810	})
811}
812
813// Destroying a frame object drops it from the server's list, and nothing is
814// sent to it afterwards.
815func TestWorkspaceFrameDestroyRemovesObject(t *testing.T) {
816	h := newCaptureHarness(t)
817	h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 2, H: 2}})
818	cc := h.client(t)
819	handles := h.handles(t, cc, 1)
820	count := func() (n int) {
821		h.s.display.Do(func() { n = len(h.s.workspaceFrames) })
822		return n
823	}
824	id, ev := cc.workspaceFrame(t, handles[0])
825	wantFrame(t, ev, [4]int32{0, 0, 2, 2})
826	if n := count(); n != 1 {
827		t.Fatalf("%d frame objects, want 1", n)
828	}
829	requestProtocol(t, cc.c, id, imagecapture.NeferwlWorkspaceFrameV1RequestDestroy)
830	cc.roundtrip(t)
831	if n := count(); n != 0 {
832		t.Fatalf("%d frame objects after destroy, want 0", n)
833	}
834	h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 3, H: 3}})
835	h.settle(t, cc)
836	noFrame(t, ev)
837}
838
839// exclusion asks get_exclusion on a session and returns the event stream.
840func (cc *cclient) exclusion(t *testing.T, session uint32) (uint32, chan sessionMsg) {
841	t.Helper()
842	id := cc.c.AllocateID()
843	ev := &exclusionEvents{msgs: make(chan sessionMsg, 8)}
844	ev.SetID(id)
845	registerWireProxy(cc.c, ev)
846	requestProtocol(t, cc.c, cc.excl, imagecapture.NeferwlCaptureExclusionManagerV1RequestGetExclusion, id, session)
847	cc.roundtrip(t)
848	return id, ev.msgs
849}
850
851// exclusionOwner opens a session and its exclusion, confirmed by core.
852func (h *captureHarness) exclusionOwner(t *testing.T) (*cclient, uint32, ports.CaptureSessionOpen, chan sessionMsg, string) {
853	t.Helper()
854	cc := h.client(t)
855	session, sev := cc.session(t, cc.outputSource(t))
856	constraints(t, cc, sev)
857	open := h.open(t)
858	_, msgs := cc.exclusion(t, session)
859	m := waitMsg(t, cc.c, msgs)
860	if uint32(m.op) != imagecapture.NeferwlCaptureExclusionV1EventToken || len(m.token) != 64 || strings.Trim(m.token, "0123456789abcdef") != "" {
861		t.Fatalf("token %+v", m)
862	}
863	if b := nextEvent[ports.CaptureExclusionBegin](t, h.events); b.Session != open.ID {
864		t.Fatalf("begin %+v", b)
865	}
866	return cc, session, open, msgs, m.token
867}
868
869// The exclusion answers with a token; a second one is busy, even from
870// another connection, and is not reported to core.
871func TestExclusionTokenAndBusy(t *testing.T) {
872	h := newCaptureHarness(t)
873	_, _, open, _, _ := h.exclusionOwner(t)
874	other := h.client(t)
875	session2, sev2 := other.session(t, other.outputSource(t))
876	constraints(t, other, sev2)
877	h.open(t)
878	_, msgs := other.exclusion(t, session2)
879	if m := waitMsg(t, other.c, msgs); uint32(m.op) != imagecapture.NeferwlCaptureExclusionV1EventFailed || m.numbers[0] != int64(imagecapture.NeferwlCaptureExclusionV1FailureBusy) {
880		t.Fatalf("second exclusion %+v", m)
881	}
882	for len(h.events) > 0 {
883		if b, ok := (<-h.events).(ports.CaptureExclusionBegin); ok {
884			t.Fatalf("a refused exclusion reached core: %+v (owner %d)", b, open.ID)
885		}
886	}
887}
888
889// get_exclusion on a session that already stopped answers
890// failed(session_stopped), whatever the reason it stopped.
891func TestExclusionOnStoppedSession(t *testing.T) {
892	h := newCaptureHarness(t)
893	cc := h.client(t)
894	// Stopped at creation (a region with nothing on the output).
895	session, ev := cc.session(t, cc.regionSource(t, 100, 100, 5, 5))
896	if g := nextSession(t, cc, ev); g[0] != evStopped {
897		t.Fatalf("event %v", g)
898	}
899	_, msgs := cc.exclusion(t, session)
900	if m := waitMsg(t, cc.c, msgs); uint32(m.op) != imagecapture.NeferwlCaptureExclusionV1EventFailed || m.numbers[0] != int64(imagecapture.NeferwlCaptureExclusionV1FailureSessionStopped) {
901		t.Fatalf("exclusion %+v", m)
902	}
903	// Stopped by core after it was registered.
904	session2, sev := cc.session(t, cc.outputSource(t))
905	constraints(t, cc, sev)
906	open := h.open(t)
907	h.send(ports.CaptureSessionState{ID: open.ID, Reason: ports.CaptureReasonOutputGone})
908	if g := nextSession(t, cc, sev); g[0] != evStopped {
909		t.Fatalf("event %v", g)
910	}
911	_, msgs = cc.exclusion(t, session2)
912	if m := waitMsg(t, cc.c, msgs); uint32(m.op) != imagecapture.NeferwlCaptureExclusionV1EventFailed || m.numbers[0] != int64(imagecapture.NeferwlCaptureExclusionV1FailureSessionStopped) {
913		t.Fatalf("exclusion %+v", m)
914	}
915	for len(h.events) > 0 {
916		if b, ok := (<-h.events).(ports.CaptureExclusionBegin); ok {
917			t.Fatalf("a refused exclusion reached core: %+v", b)
918		}
919	}
920}
921
922// One session gets one live exclusion object.
923func TestExclusionAlreadyExcluded(t *testing.T) {
924	h := newCaptureHarness(t)
925	cc, session, _, _, _ := h.exclusionOwner(t)
926	requestProtocol(t, cc.c, cc.excl, imagecapture.NeferwlCaptureExclusionManagerV1RequestGetExclusion, cc.c.AllocateID(), session)
927	expectProtocolError(t, cc.c, cc.excl, uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorAlreadyExcluded))
928}
929
930// Destroying the exclusion ends it in core, detaches its layers and frees
931// the slot; the session lives on.
932func TestExclusionDestroyFreesTheSlot(t *testing.T) {
933	h := newCaptureHarness(t)
934	cc, session, open, _, _ := h.exclusionOwner(t)
935	h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 1})
936	h.settle(t, cc)
937	var excl uint32
938	h.s.display.Do(func() { excl = h.s.excl.res.ID() })
939	requestProtocol(t, cc.c, excl, imagecapture.NeferwlCaptureExclusionV1RequestDestroy)
940	cc.roundtrip(t)
941	if e := nextEvent[ports.CaptureExclusionEnd](t, h.events); e.Session != open.ID {
942		t.Fatalf("end %+v", e)
943	}
944	_, msgs := cc.exclusion(t, session)
945	if m := waitMsg(t, cc.c, msgs); uint32(m.op) != imagecapture.NeferwlCaptureExclusionV1EventToken {
946		t.Fatalf("new exclusion %+v", m)
947	}
948}
949
950// Frames of the session that owns the exclusion are fenced: refused until
951// core knows the exclusion, then tagged with its revision. Every other
952// capture, wlr-screencopy included, carries no tag.
953func TestExclusionFrameTagging(t *testing.T) {
954	h := newCaptureHarness(t)
955	owner, session, open, _, _ := h.exclusionOwner(t)
956	// Core has not confirmed the exclusion: the frame fails.
957	ev := owner.extFrame(t, session, 4, 4)
958	if !eventsInclude(ev, frameFailed) {
959		t.Fatal("a frame before core confirmed the exclusion was served")
960	}
961	noCapture(t, h.captures)
962	h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 9})
963	h.settle(t, owner)
964	owner.extFrame(t, session, 4, 4)
965	r := receiveCapture(t, h.captures)
966	if !r.Exclude || r.Session != open.ID || r.CaptureRevision != 9 {
967		t.Fatalf("owner frame %+v", r)
968	}
969	h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
970	// A wlr-screencopy capture, even of the owner's connection, is untagged.
971	owner.wlrFrame(t)
972	r = receiveCapture(t, h.captures)
973	if r.Exclude || r.Session != 0 || r.CaptureRevision != 0 || r.Workspace != 0 {
974		t.Fatalf("wlr capture tagged: %+v", r)
975	}
976	h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
977	// So is a plain ext session of another client.
978	other := h.client(t)
979	session2, sev := other.session(t, other.outputSource(t))
980	constraints(t, other, sev)
981	h.open(t)
982	other.extFrame(t, session2, 4, 4)
983	r = receiveCapture(t, h.captures)
984	if r.Exclude || r.CaptureRevision != 0 {
985		t.Fatalf("another session's frame tagged: %+v", r)
986	}
987}
988
989// hud is a second connection: a layer surface it attaches with the token.
990type hud struct {
991	*cclient
992	surface, layer uint32
993}
994
995func (h *captureHarness) hud(t *testing.T, layer ports.Layer, keyboard uint32) *hud {
996	t.Helper()
997	return h.hudVersion(t, layer, keyboard, 1)
998}
999
1000func (h *captureHarness) hudVersion(t *testing.T, layer ports.Layer, keyboard, shellVersion uint32) *hud {
1001	t.Helper()
1002	pc := h.client(t)
1003	comp := bindProtocol(t, pc.c, "wl_compositor")
1004	shell := bindVersion(t, pc.c, "zwlr_layer_shell_v1", shellVersion)
1005	surf, l := pc.c.AllocateID(), pc.c.AllocateID()
1006	requestProtocol(t, pc.c, comp, wayland.CompositorRequestCreateSurface, surf)
1007	registerProtocol(t, pc.c, surf)
1008	requestProtocol(t, pc.c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, l, surf, pc.output, uint32(layer), "hud")
1009	registerProtocol(t, pc.c, l)
1010	if keyboard != 0 {
1011		requestProtocol(t, pc.c, l, wlrlayershell.ZwlrLayerSurfaceV1RequestSetKeyboardInteractivity, keyboard)
1012	}
1013	if err := pc.c.Roundtrip(); err != nil {
1014		t.Fatal(err)
1015	}
1016	return &hud{cclient: pc, surface: surf, layer: l}
1017}
1018
1019// attach sends attach_surface and returns the attachment's event stream.
1020func (hd *hud) attach(t *testing.T, token string) chan sessionMsg {
1021	t.Helper()
1022	return hd.attachSurface(t, token, hd.surface)
1023}
1024
1025func (hd *hud) attachSurface(t *testing.T, token string, surface uint32) chan sessionMsg {
1026	t.Helper()
1027	id := hd.c.AllocateID()
1028	ev := &attachEvents{msgs: make(chan sessionMsg, 8)}
1029	ev.SetID(id)
1030	registerWireProxy(hd.c, ev)
1031	requestProtocol(t, hd.c, hd.excl, imagecapture.NeferwlCaptureExclusionManagerV1RequestAttachSurface, id, token, surface)
1032	if err := hd.c.Roundtrip(); err != nil {
1033		t.Fatal(err)
1034	}
1035	return ev.msgs
1036}
1037
1038// expectAttachment waits for one attachment event of the given opcode and first argument.
1039func expectAttachment(t *testing.T, c *wlturbo.Display, ch chan sessionMsg, op uint32, arg ...int64) {
1040	t.Helper()
1041	m := waitMsg(t, c, ch)
1042	if uint32(m.op) != op || (len(arg) > 0 && m.numbers[0] != arg[0]) {
1043		t.Fatalf("attachment event %+v, want op %d %v", m, op, arg)
1044	}
1045}
1046
1047// waitMsg waits for a message on ch, dispatching the client meanwhile.
1048func waitMsg(t *testing.T, c *wlturbo.Display, ch chan sessionMsg) sessionMsg {
1049	t.Helper()
1050	deadline := time.Now().Add(2 * time.Second)
1051	for time.Now().Before(deadline) {
1052		select {
1053		case m := <-ch:
1054			return m
1055		default:
1056		}
1057		if err := c.Roundtrip(); err != nil {
1058			t.Fatal(err)
1059		}
1060		time.Sleep(time.Millisecond)
1061	}
1062	t.Fatal("timed out waiting for an event")
1063	return sessionMsg{}
1064}
1065
1066func noMessage(t *testing.T, c *wlturbo.Display, ch chan sessionMsg) {
1067	t.Helper()
1068	if err := c.Roundtrip(); err != nil {
1069		t.Fatal(err)
1070	}
1071	select {
1072	case m := <-ch:
1073		t.Fatalf("unexpected event %+v", m)
1074	default:
1075	}
1076}
1077
1078func nextEvent[T ports.ClientEvent](t *testing.T, ch chan ports.ClientEvent) T {
1079	t.Helper()
1080	for {
1081		select {
1082		case ev := <-ch:
1083			if v, ok := ev.(T); ok {
1084				return v
1085			}
1086		case <-time.After(2 * time.Second):
1087			t.Fatal("timed out waiting for a client event")
1088		}
1089	}
1090}
1091
1092// Attach works from another connection with the token, reaches core before
1093// any content, and is confirmed to the client only once core lists the layer.
1094func TestExclusionAttach(t *testing.T) {
1095	h := newCaptureHarness(t)
1096	_, _, open, _, token := h.exclusionOwner(t)
1097	hd := h.hud(t, ports.LayerOverlay, 2)
1098	// A wrong token fails that attachment without touching the exclusion.
1099	bad := hd.attach(t, strings.Repeat("0", 64))
1100	expectAttachment(t, hd.c, bad, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureUnknownToken))
1101	good := hd.attach(t, token)
1102	layer := nextEvent[ports.CaptureExclusionLayer](t, h.events)
1103	if layer.Session != open.ID || !layer.Attached || layer.Layer == 0 {
1104		t.Fatalf("layer %+v", layer)
1105	}
1106	noMessage(t, hd.c, good) // not confirmed yet
1107	st := active(open.ID)
1108	st.Exclusion, st.Revision = true, 2
1109	h.send(st)
1110	noMessage(t, hd.c, good) // a state that does not list the layer confirms nothing
1111	st.Revision, st.Layers = 3, []ports.WindowID{layer.Layer}
1112	h.send(st)
1113	expectAttachment(t, hd.c, good, imagecapture.NeferwlCaptureLayerV1EventAttached)
1114	// Attaching the same surface again fails on its own object.
1115	again := hd.attach(t, token)
1116	expectAttachment(t, hd.c, again, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureAlreadyAttached))
1117	// Destroying the layer detaches it: LayerChanged (the unmap) first, then
1118	// the detach, on the same ordered channel.
1119	requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
1120	if err := hd.c.Roundtrip(); err != nil {
1121		t.Fatal(err)
1122	}
1123	expectAttachment(t, hd.c, good, imagecapture.NeferwlCaptureLayerV1EventDetached, int64(imagecapture.NeferwlCaptureLayerV1DetachReasonLayerDestroyed))
1124	if l := nextEvent[ports.CaptureExclusionLayer](t, h.events); l.Attached || l.Layer != layer.Layer {
1125		t.Fatalf("detach %+v", l)
1126	}
1127}
1128
1129// Once the exclusion ended the token is dead.
1130func TestExclusionTokenDeadAfterEnd(t *testing.T) {
1131	h := newCaptureHarness(t)
1132	owner, session, _, _, token := h.exclusionOwner(t)
1133	requestProtocol(t, owner.c, session, ext.ExtImageCopyCaptureSessionV1RequestDestroy)
1134	owner.roundtrip(t)
1135	hd := h.hud(t, ports.LayerOverlay, 0)
1136	att := hd.attach(t, token)
1137	expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureUnknownToken))
1138}
1139
1140// Order on the event channel: a mapped attached layer that is destroyed
1141// queues its LayerChanged removal before the detach reaches core.
1142func TestExclusionDetachOrdering(t *testing.T) {
1143	h := newCaptureHarness(t)
1144	_, _, open, _, token := h.exclusionOwner(t)
1145	hd := h.hud(t, ports.LayerTop, 0)
1146	att := hd.attach(t, token)
1147	layer := nextEvent[ports.CaptureExclusionLayer](t, h.events)
1148	st := active(open.ID)
1149	st.Exclusion, st.Revision, st.Layers = true, 2, []ports.WindowID{layer.Layer}
1150	h.send(st)
1151	expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventAttached)
1152	mapLayer(t, hd)
1153	if l := nextEvent[ports.LayerChanged](t, h.events); len(l.Layers) != 1 {
1154		t.Fatalf("mapped %+v", l)
1155	}
1156	requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
1157	if err := hd.c.Roundtrip(); err != nil {
1158		t.Fatal(err)
1159	}
1160	var order []string
1161	for len(order) < 2 {
1162		switch v := (<-h.events).(type) {
1163		case ports.LayerChanged:
1164			if len(v.Layers) == 0 {
1165				order = append(order, "unmap")
1166			}
1167		case ports.CaptureExclusionLayer:
1168			if !v.Attached {
1169				order = append(order, "detach")
1170			}
1171		}
1172	}
1173	if order[0] != "unmap" || order[1] != "detach" {
1174		t.Fatalf("order %v: the exclusion must outlive the layer's last frame", order)
1175	}
1176}
1177
1178// mapLayer configures, acks and commits a buffer on the hud's layer.
1179func mapLayer(t *testing.T, hd *hud) {
1180	t.Helper()
1181	requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1))
1182	requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
1183	cfg := &layerProxy{configured: make(chan [3]uint32, 4)}
1184	cfg.SetID(hd.layer)
1185	registerWireProxy(hd.c, cfg)
1186	if err := hd.c.Roundtrip(); err != nil {
1187		t.Fatal(err)
1188	}
1189	c := <-cfg.configured
1190	requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, c[0])
1191	_, buf, fd := captureSmallBuffer(t, hd.c)
1192	t.Cleanup(func() { _ = unix.Close(fd) })
1193	requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
1194	requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
1195	if err := hd.c.Roundtrip(); err != nil {
1196		t.Fatal(err)
1197	}
1198}
1199
1200// Ending the exclusion (here with its session) detaches every attachment,
1201// closes the layer surfaces (so none lingers into the next exclusion) and
1202// removes them before core is told the session closed.
1203func TestExclusionEndClosesLayers(t *testing.T) {
1204	h := newCaptureHarness(t)
1205	owner, session, open, _, token := h.exclusionOwner(t)
1206	hd := h.hud(t, ports.LayerOverlay, 0)
1207	closed := make(chan sessionMsg, 4)
1208	lp := &layerClosedProxy{msgs: closed}
1209	lp.SetID(hd.layer)
1210	registerWireProxy(hd.c, lp)
1211	confirmed := hd.attach(t, token)
1212	layer := nextEvent[ports.CaptureExclusionLayer](t, h.events)
1213	st := active(open.ID)
1214	st.Exclusion, st.Revision, st.Layers = true, 2, []ports.WindowID{layer.Layer}
1215	h.send(st)
1216	expectAttachment(t, hd.c, confirmed, imagecapture.NeferwlCaptureLayerV1EventAttached)
1217	pending := hd.attachSurfaceNew(t, token)
1218	requestProtocol(t, owner.c, session, ext.ExtImageCopyCaptureSessionV1RequestDestroy)
1219	owner.roundtrip(t)
1220	expectAttachment(t, hd.c, confirmed, imagecapture.NeferwlCaptureLayerV1EventDetached, int64(imagecapture.NeferwlCaptureLayerV1DetachReasonExclusionEnded))
1221	expectAttachment(t, hd.c, pending, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureExclusionEnded))
1222	if m := waitMsg(t, hd.c, closed); uint32(m.op) != uint32(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
1223		t.Fatalf("layer event %+v", m)
1224	}
1225	if e := nextEvent[ports.CaptureSessionClose](t, h.events); e.ID != open.ID {
1226		t.Fatalf("close %+v", e)
1227	}
1228}
1229
1230type layerClosedProxy struct {
1231	wlturbo.BaseProxy
1232	msgs chan sessionMsg
1233}
1234
1235func (p *layerClosedProxy) Dispatch(e *wlturbo.Event) {
1236	if e.Opcode == uint16(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
1237		p.msgs <- sessionMsg{op: e.Opcode}
1238	}
1239}
1240
1241// attachSurfaceNew attaches a second, fresh layer surface of the hud and returns its stream.
1242func (hd *hud) attachSurfaceNew(t *testing.T, token string) chan sessionMsg {
1243	t.Helper()
1244	comp := bindProtocol(t, hd.c, "wl_compositor")
1245	shell := bindProtocol(t, hd.c, "zwlr_layer_shell_v1")
1246	surf, l := hd.c.AllocateID(), hd.c.AllocateID()
1247	requestProtocol(t, hd.c, comp, wayland.CompositorRequestCreateSurface, surf)
1248	registerProtocol(t, hd.c, surf)
1249	requestProtocol(t, hd.c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, l, surf, hd.output, uint32(ports.LayerTop), "hud2")
1250	registerProtocol(t, hd.c, l)
1251	return hd.attachSurface(t, token, surf)
1252}
1253
1254func TestExclusionAttachRefusals(t *testing.T) {
1255	for _, tc := range []struct {
1256		name     string
1257		layer    ports.Layer
1258		keyboard uint32
1259		code     uint32
1260	}{
1261		{"background layer", ports.LayerBackground, 0, uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorInvalidLayer)},
1262		{"bottom layer", ports.LayerBottom, 0, uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorInvalidLayer)},
1263		{"exclusive keyboard", ports.LayerTop, 1, uint32(imagecapture.NeferwlCaptureExclusionManagerV1ErrorExclusiveKeyboard)},
1264	} {
1265		t.Run(tc.name, func(t *testing.T) {
1266			h := newCaptureHarness(t)
1267			_, _, _, _, token := h.exclusionOwner(t)
1268			hd := h.hud(t, tc.layer, tc.keyboard)
1269			requestProtocol(t, hd.c, hd.excl, imagecapture.NeferwlCaptureExclusionManagerV1RequestAttachSurface, hd.c.AllocateID(), token, hd.surface)
1270			expectProtocolError(t, hd.c, hd.excl, tc.code)
1271			for len(h.events) > 0 {
1272				if l, ok := (<-h.events).(ports.CaptureExclusionLayer); ok {
1273					t.Fatalf("a refused attach reached core: %+v", l)
1274				}
1275			}
1276		})
1277	}
1278}
1279
1280func TestExclusionAttachLimit(t *testing.T) {
1281	h := newCaptureHarness(t)
1282	_, _, _, _, token := h.exclusionOwner(t)
1283	hd := h.hud(t, ports.LayerTop, 0)
1284	hd.attach(t, token)
1285	for i := 1; i < ports.MaxExclusionLayers; i++ {
1286		hd.attachSurfaceNew(t, token)
1287	}
1288	over := hd.attachSurfaceNew(t, token)
1289	expectAttachment(t, hd.c, over, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureTooManyLayers))
1290	// Events reach core through a forwarding goroutine: wait for each.
1291	for range ports.MaxExclusionLayers {
1292		if l := nextEvent[ports.CaptureExclusionLayer](t, h.events); !l.Attached {
1293			t.Fatalf("detach %+v", l)
1294		}
1295	}
1296	// The refused one never follows: it was refused before the failed event
1297	// the client already read, so a short wait covers the forwarder.
1298	deadline := time.After(50 * time.Millisecond)
1299	for {
1300		select {
1301		case ev := <-h.events:
1302			if l, ok := ev.(ports.CaptureExclusionLayer); ok && l.Attached {
1303				t.Fatalf("layer over the limit reached core: %+v", l)
1304			}
1305		case <-deadline:
1306			return
1307		}
1308	}
1309}
1310
1311// An attached layer that commits a layer below top is closed and detached
1312// once, and its unmap reaches core before the detach.
1313func TestExclusionLayerMovedBelowTop(t *testing.T) {
1314	h := newCaptureHarness(t)
1315	_, _, open, _, token := h.exclusionOwner(t)
1316	hd := h.hudVersion(t, ports.LayerTop, 0, 2)
1317	att := hd.attach(t, token)
1318	layer := nextEvent[ports.CaptureExclusionLayer](t, h.events)
1319	st := active(open.ID)
1320	st.Exclusion, st.Revision, st.Layers = true, 2, []ports.WindowID{layer.Layer}
1321	h.send(st)
1322	expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventAttached)
1323	mapLayer(t, hd)
1324	if l := nextEvent[ports.LayerChanged](t, h.events); len(l.Layers) != 1 {
1325		t.Fatalf("mapped %+v", l)
1326	}
1327	closed := make(chan sessionMsg, 4)
1328	lp := &layerClosedProxy{msgs: closed}
1329	lp.SetID(hd.layer)
1330	registerWireProxy(hd.c, lp)
1331	requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetLayer, uint32(ports.LayerBottom))
1332	requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
1333	if err := hd.c.Roundtrip(); err != nil {
1334		t.Fatal(err)
1335	}
1336	expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventDetached, int64(imagecapture.NeferwlCaptureLayerV1DetachReasonLayerDestroyed))
1337	if m := waitMsg(t, hd.c, closed); uint32(m.op) != uint32(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
1338		t.Fatalf("layer event %+v", m)
1339	}
1340	noMessage(t, hd.c, att) // detached exactly once
1341	var order []string
1342	for len(order) < 2 {
1343		switch v := (<-h.events).(type) {
1344		case ports.LayerChanged:
1345			if len(v.Layers) == 0 {
1346				order = append(order, "unmap")
1347			}
1348		case ports.CaptureExclusionLayer:
1349			if !v.Attached {
1350				order = append(order, "detach")
1351			}
1352		}
1353	}
1354	if order[0] != "unmap" || order[1] != "detach" {
1355		t.Fatalf("order %v", order)
1356	}
1357}
1358
1359// An attached layer asking for keyboard interactivity, before or after the
1360// attach, is never reported with it to core.
1361func TestExclusionLayerKeyboardNone(t *testing.T) {
1362	h := newCaptureHarness(t)
1363	_, _, open, _, token := h.exclusionOwner(t)
1364	hd := h.hud(t, ports.LayerOverlay, 2) // on demand before the attach
1365	att := hd.attach(t, token)
1366	layer := nextEvent[ports.CaptureExclusionLayer](t, h.events)
1367	st := active(open.ID)
1368	st.Exclusion, st.Revision, st.Layers = true, 2, []ports.WindowID{layer.Layer}
1369	h.send(st)
1370	expectAttachment(t, hd.c, att, imagecapture.NeferwlCaptureLayerV1EventAttached)
1371	requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetKeyboardInteractivity, uint32(2)) // and after
1372	mapLayer(t, hd)
1373	l := nextEvent[ports.LayerChanged](t, h.events)
1374	if len(l.Layers) != 1 || l.Layers[0].Keyboard != 0 {
1375		t.Fatalf("layer reported with keyboard: %+v", l.Layers)
1376	}
1377}
1378
1379// frameTaken waits for the next CaptureFrameTaken core is told of.
1380func (h *captureHarness) frameTaken(t *testing.T) ports.CaptureFrameTaken {
1381	t.Helper()
1382	return nextEvent[ports.CaptureFrameTaken](t, h.events)
1383}
1384
1385// Every capture is reported to core, which shows the indicator for it: a
1386// wlr-screencopy frame (no session) and a frame of each kind of ext source,
1387// with the target the client asked for.
1388func TestEveryCaptureFrameIsReportedToCore(t *testing.T) {
1389	h := newCaptureHarness(t)
1390	h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}})
1391	cc := h.client(t)
1392	// wlr-screencopy, whole output.
1393	cc.wlrFrame(t)
1394	r := receiveCapture(t, h.captures)
1395	if got := h.frameTaken(t); got != (ports.CaptureFrameTaken{Output: "HEADLESS-1"}) {
1396		t.Fatalf("wlr frame reported %+v", got)
1397	}
1398	h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
1399	if got := h.frameTaken(t); got != (ports.CaptureFrameTaken{Output: "HEADLESS-1"}) {
1400		t.Fatalf("wlr completion reported %+v", got)
1401	}
1402	// ext output source.
1403	session, ev := cc.session(t, cc.outputSource(t))
1404	constraints(t, cc, ev)
1405	open := h.open(t)
1406	cc.extFrame(t, session, 4, 4)
1407	receiveCapture(t, h.captures)
1408	if got := h.frameTaken(t); got != (ports.CaptureFrameTaken{Session: open.ID, Output: "HEADLESS-1"}) {
1409		t.Fatalf("ext output frame reported %+v", got)
1410	}
1411	// ext region source: the logical rectangle as asked.
1412	session, ev = cc.session(t, cc.regionSource(t, 1, 1, 2, 2))
1413	constraints(t, cc, ev)
1414	open = h.open(t)
1415	cc.extFrame(t, session, 2, 2)
1416	receiveCapture(t, h.captures)
1417	if got := h.frameTaken(t); got != (ports.CaptureFrameTaken{Session: open.ID, Output: "HEADLESS-1", Region: ports.Rect{X: 1, Y: 1, W: 2, H: 2}}) {
1418		t.Fatalf("ext region frame reported %+v", got)
1419	}
1420	// ext workspace source.
1421	handles := h.handles(t, cc, 1)
1422	session, ev = cc.session(t, cc.workspaceSource(t, handles[0]))
1423	constraints(t, cc, ev)
1424	open = h.open(t)
1425	cc.extFrame(t, session, 4, 4)
1426	receiveCapture(t, h.captures)
1427	if got := h.frameTaken(t); got != (ports.CaptureFrameTaken{Session: open.ID, Workspace: 42}) {
1428		t.Fatalf("ext workspace frame reported %+v", got)
1429	}
1430}
1431
1432// Every capture request asks the output owner to hold it until the indicator
1433// is on screen, whatever protocol or source made it.
1434func TestEveryCaptureRequestAsksForItsIndicator(t *testing.T) {
1435	h := newCaptureHarness(t)
1436	h.snapshot(t, ports.WorkspaceInfo{ID: 42, Name: "1", Active: true, Frame: ports.Rect{W: 4, H: 4}})
1437	cc := h.client(t)
1438	cc.wlrFrame(t)
1439	if r := receiveCapture(t, h.captures); !r.Indicate {
1440		t.Fatalf("wlr request %+v", r)
1441	}
1442	session, ev := cc.session(t, cc.outputSource(t))
1443	constraints(t, cc, ev)
1444	h.open(t)
1445	cc.extFrame(t, session, 4, 4)
1446	if r := receiveCapture(t, h.captures); !r.Indicate {
1447		t.Fatalf("ext output request %+v", r)
1448	}
1449	handles := h.handles(t, cc, 1)
1450	session, ev = cc.session(t, cc.workspaceSource(t, handles[0]))
1451	constraints(t, cc, ev)
1452	h.open(t)
1453	cc.extFrame(t, session, 4, 4)
1454	if r := receiveCapture(t, h.captures); !r.Indicate || r.Workspace != 42 {
1455		t.Fatalf("ext workspace request %+v", r)
1456	}
1457}
1458
1459// A capture the compositor refuses is not reported: nothing was captured.
1460func TestRefusedCaptureIsNotReported(t *testing.T) {
1461	h := newCaptureHarness(t)
1462	cc := h.client(t)
1463	session, ev := cc.session(t, cc.outputSource(t))
1464	constraints(t, cc, ev)
1465	h.open(t)
1466	// A wrong-sized buffer is refused before it reaches the renderer.
1467	cc.extFrame(t, session, 2, 2)
1468	noCapture(t, h.captures)
1469	h.settle(t, cc)
1470	for len(h.events) > 0 {
1471		if v, ok := (<-h.events).(ports.CaptureFrameTaken); ok {
1472			t.Fatalf("refused capture reported: %+v", v)
1473		}
1474	}
1475}
1476
1477// logicalRegion gives core the logical rectangle of a physical region.
1478func TestLogicalRegion(t *testing.T) {
1479	o := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Width: 8, Height: 8}, Scale: 2}}
1480	for _, tc := range []struct {
1481		phys image.Rectangle
1482		want ports.Rect
1483	}{
1484		{image.Rect(0, 0, 8, 8), ports.Rect{}},
1485		{image.Rect(2, 2, 6, 4), ports.Rect{X: 1, Y: 1, W: 2, H: 1}},
1486		{image.Rect(1, 1, 5, 3), ports.Rect{X: 0, Y: 0, W: 3, H: 2}}, // rounded outwards
1487	} {
1488		if got := logicalRegion(o, tc.phys); got != tc.want {
1489			t.Errorf("%v: %+v, want %+v", tc.phys, got, tc.want)
1490		}
1491	}
1492}

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 23

Tags 10

↑↓ moveenter openesc close