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

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