imagecapture_test.go RawHistoryFolder

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

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 25

Tags 10

↑↓ moveenter openesc close