v0.4.0 76bcd84

copycapture.go RawHistoryFolder

1package wayland
2
3import (
4	"errors"
5	"fmt"
6	"time"
7
8	imagecapturesource "github.com/bnema/go-wayland-bindings/client/extimagecapturesource"
9	imagecopycapture "github.com/bnema/go-wayland-bindings/client/extimagecopycapture"
10
11	"github.com/bnema/nefercap/internal/ports"
12)
13
14// wl_shm format values for the two supported native little-endian layouts.
15const (
16	shmARGB8888 = uint32(ports.ARGB8888)
17	shmXRGB8888 = uint32(ports.XRGB8888)
18)
19
20// maxFrameAttempts bounds the retries of one frame after failures that the
21// protocol calls recoverable.
22const maxFrameAttempts = 4
23
24var errCompositorFailedCapture = errors.New("wayland: compositor failed the capture")
25
26// errCaptureRefused is ErrCaptureStopped for a session that stopped before
27// serving any frame.
28var errCaptureRefused = fmt.Errorf("%w: compositor refused capture (on NeferWL, list this executable in /etc/neferwl/capture-allow)", ports.ErrCaptureStopped)
29
30// constraints is one batch of buffer constraints, valid once done arrived.
31type constraints struct {
32	width, height uint32
33	haveSize      bool
34	format        uint32 // first supported shm format of the batch
35	haveFormat    bool
36	sawShm        bool
37	unsupported   uint32 // last rejected shm format
38}
39
40// session is one ext_image_copy_capture_session_v1. Its events are dispatched
41// on the goroutine that owns the operation.
42type session struct {
43	proxy *imagecopycapture.ExtImageCopyCaptureSession
44	key   sessionKey
45
46	pending constraints
47	current constraints
48	gen     uint64 // number of constraint batches completed
49	stopped bool
50	dead    bool // destroy sent
51
52	exclusion *exclusion // nil until requested
53
54	// Frame pacing. At most one frame is in flight. The last completed frame
55	// lives in buffer cur and stays untouched while the next one is written to
56	// the other buffer, so a Capture that finds the compositor idle (it waits
57	// for damage after the first frame) can repeat the last frame at once.
58	frame        captureFrame  // outcome of the in-flight frame, reset per frame
59	inflight     *captureFrame // &frame while a frame is in flight, else nil
60	inflightBuf  int
61	inflightGeom bufGeom // buffer layout the in-flight frame was made for
62	haveLast     bool
63	cur          int
64	lastGeom     bufGeom
65	served       int // frames handed out by this session
66	fails        int // consecutive failed frames
67
68	// Frame event handlers, built once and shared by every frame proxy.
69	onTransform func(uint32)
70	onReady     func()
71	onFailed    func(uint32)
72}
73
74// newSession wraps a created session proxy and subscribes to its events.
75func newSession(proxy *imagecopycapture.ExtImageCopyCaptureSession, key sessionKey) *session {
76	c := &session{proxy: proxy, key: key}
77	proxy.OnBufferSize(func(width, height uint32) {
78		c.pending.width, c.pending.height, c.pending.haveSize = width, height, true
79	})
80	proxy.OnShmFormat(func(format uint32) {
81		c.pending.sawShm = true
82		switch {
83		case format != shmARGB8888 && format != shmXRGB8888:
84			c.pending.unsupported = format
85		case !c.pending.haveFormat:
86			c.pending.format, c.pending.haveFormat = format, true
87		}
88	})
89	proxy.OnDone(func() {
90		c.current, c.pending = c.pending, constraints{}
91		c.gen++
92	})
93	proxy.OnStopped(func() { c.stopped = true })
94	c.onTransform = func(transform uint32) { c.frame.transform = transform }
95	c.onReady = func() { c.frame.ready = true }
96	c.onFailed = func(reason uint32) { c.frame.failed, c.frame.reason = true, reason }
97	return c
98}
99
100// captureFrame is one ext_image_copy_capture_frame_v1 and its outcome. The
101// session owns a single one, reset for each frame.
102type captureFrame struct {
103	proxy     *imagecopycapture.ExtImageCopyCaptureFrame
104	transform uint32
105	ready     bool
106	failed    bool
107	reason    uint32
108}
109
110// newCaptureFrame creates the next frame of c, resetting its outcome, and
111// subscribes the session's shared handlers to its events.
112func newCaptureFrame(c *session) (*captureFrame, error) {
113	proxy, err := c.proxy.CreateFrame()
114	if err != nil {
115		return nil, err
116	}
117	c.frame = captureFrame{proxy: proxy}
118	proxy.OnTransform(c.onTransform)
119	proxy.OnReady(c.onReady)
120	proxy.OnFailed(c.onFailed)
121	return &c.frame, nil
122}
123
124// sessionKey identifies what a session captures. Crop sessions have a zero
125// region: the region is cut from each frame.
126type sessionKey struct {
127	output    uint32
128	workspace uint64
129	region    ports.Region
130}
131
132func validRegion(r ports.Region) error {
133	if r == (ports.Region{}) {
134		return nil
135	}
136	if r.X < 0 || r.Y < 0 || r.Width < 1 || r.Height < 1 ||
137		r.X > ports.MaxDimension || r.Y > ports.MaxDimension ||
138		r.Width > ports.MaxDimension || r.Height > ports.MaxDimension {
139		return fmt.Errorf("%w: %+v", ports.ErrInvalidRegion, r)
140	}
141	return nil
142}
143
144// captureTarget resolves t to a session, creating or replacing it, and copies
145// one frame. The caller holds opMu.
146func (s *Source) captureTarget(t ports.Target) (ports.Frame, error) {
147	out := s.outputs[t.OutputID]
148	if out == nil {
149		return ports.Frame{}, ports.ErrOutputNotFound
150	}
151	key, crop, err := s.resolve(t, out)
152	if err != nil {
153		return ports.Frame{}, err
154	}
155	sess, err := s.sessionFor(key, out)
156	if err != nil {
157		return ports.Frame{}, err
158	}
159	frame, err := s.nextFrame(sess, out)
160	if err != nil {
161		return ports.Frame{}, err
162	}
163	if crop == (ports.Region{}) {
164		return frame, nil
165	}
166	return cropFrame(frame, crop, out)
167}
168
169// resolve maps a target to its session key and the crop to apply per frame.
170func (s *Source) resolve(t ports.Target, out *output) (sessionKey, ports.Region, error) {
171	if t.WorkspaceID != 0 {
172		return s.workspaceKey(t, out)
173	}
174	if t.Region != (ports.Region{}) && s.ext.source != nil {
175		return sessionKey{output: out.id, region: t.Region}, ports.Region{}, nil
176	}
177	return sessionKey{output: out.id}, t.Region, nil
178}
179
180// sessionFor returns the live session for key. A different key replaces the
181// current session, which ends its exclusion.
182func (s *Source) sessionFor(key sessionKey, out *output) (*session, error) {
183	if c := s.sess; c != nil {
184		if c.key == key && (!c.stopped || c.exclusion != nil) {
185			// A stopped session with an exclusion stays stopped: replacing it
186			// would quietly lose the exclusion in the middle of a recording.
187			return c, nil
188		}
189		s.dropSession()
190	}
191	var src *imagecapturesource.ExtImageCaptureSource
192	var err error
193	switch {
194	case key.workspace != 0:
195		src, err = s.createWorkspaceSource(key.workspace)
196	case key.region != (ports.Region{}):
197		src, err = s.createRegionSource(out, key.region)
198	default:
199		src, err = s.outSource.CreateSource(out.proxy)
200	}
201	if err != nil {
202		s.terminate()
203		return nil, fmt.Errorf("wayland: create capture source: %w", err)
204	}
205	proxy, err := s.copyManager.CreateSession(src, 0)
206	_ = src.Destroy()
207	if err != nil {
208		s.terminate()
209		return nil, fmt.Errorf("wayland: create capture session: %w", err)
210	}
211	c := newSession(proxy, key)
212	s.sess = c
213	return c, nil
214}
215
216// dropSession destroys the current session, if any.
217func (s *Source) dropSession() {
218	c := s.sess
219	s.sess = nil
220	if c == nil || c.dead {
221		return
222	}
223	c.dead = true
224	s.destroyFrame(c)
225	if c.exclusion != nil {
226		c.exclusion.destroy(s)
227	}
228	_ = c.proxy.Destroy()
229}
230
231// nextFrame returns the session's current frame.
232//
233// The first frame of a session is waited for: the protocol guarantees it does
234// not wait for damage. After that the compositor may hold a capture back until
235// the screen changes, so a capture stays pending across calls (still one frame
236// in flight) and a call that finds it unfinished after pollWait returns the
237// last completed frame again. The caller's schedule is then never stretched by
238// a static screen, and a recording repeats that frame.
239//
240// A failed frame is retried after a roundtrip, at most maxFrameAttempts
241// failures in a row (the count survives calls and restarts at each completed
242// frame): stopped ends the capture, buffer_constraints is retried with the
243// latest constraints, which are the current ones when the compositor sent no
244// new batch. An unknown failure is retried the same way, unless a last frame
245// exists: that one is repeated, like when a capture is still pending.
246func (s *Source) nextFrame(c *session, out *output) (ports.Frame, error) {
247	for {
248		for c.gen == 0 && !c.stopped {
249			if err := s.dispatch(); err != nil {
250				return ports.Frame{}, err
251			}
252		}
253		if c.stopped {
254			return ports.Frame{}, s.stoppedError(c, out)
255		}
256		g, err := s.geometry(c)
257		if err != nil {
258			return ports.Frame{}, err
259		}
260		// A new constraint batch with the same layout changes nothing: the last
261		// frame stays valid and the pending capture stays in flight.
262		if c.haveLast && c.lastGeom != g {
263			c.haveLast = false
264		}
265		if c.inflight != nil && c.inflightGeom != g {
266			s.destroyFrame(c)
267		}
268		if c.inflight == nil {
269			if err := s.startFrame(c, g); err != nil {
270				return ports.Frame{}, err
271			}
272		}
273		if err := s.awaitFrame(c, c.haveLast); err != nil {
274			return ports.Frame{}, err
275		}
276		f := c.inflight
277		switch {
278		case c.stopped:
279			return ports.Frame{}, s.stoppedError(c, out)
280		case f.ready:
281			return s.complete(c, g, f)
282		case f.failed:
283			if err := s.retryAfterFailure(c, f); err != nil {
284				if c.stopped {
285					return ports.Frame{}, s.stoppedError(c, out)
286				}
287				c.fails = 0 // the next call starts afresh
288				return ports.Frame{}, err
289			}
290			if c.stopped {
291				return ports.Frame{}, s.stoppedError(c, out)
292			}
293			if c.haveLast && f.reason == imagecopycapture.FAILURE_REASON_UNKNOWN {
294				return s.lastFrame(c)
295			}
296		default: // still pending after the poll: repeat the last frame
297			return s.lastFrame(c)
298		}
299	}
300}
301
302// awaitFrame dispatches until the in-flight frame ended or the session
303// stopped. With poll set it gives up after pollWait.
304func (s *Source) awaitFrame(c *session, poll bool) error {
305	f := c.inflight
306	var deadline time.Time
307	if poll {
308		deadline = time.Now().Add(s.pollWait)
309	}
310	for !f.ready && !f.failed && !c.stopped {
311		timedOut, err := s.dispatchUntil(deadline)
312		if err != nil {
313			return err
314		}
315		if timedOut {
316			return nil
317		}
318	}
319	return nil
320}
321
322// retryAfterFailure handles a failed frame: one roundtrip, so that a stop or a
323// new constraint batch that follows the failure is seen, then a retry.
324func (s *Source) retryAfterFailure(c *session, f *captureFrame) error {
325	s.destroyFrame(c)
326	if f.reason == imagecopycapture.FAILURE_REASON_STOPPED {
327		c.stopped = true
328		return nil
329	}
330	if c.fails++; c.fails >= maxFrameAttempts {
331		return errCompositorFailedCapture
332	}
333	switch f.reason {
334	case imagecopycapture.FAILURE_REASON_BUFFER_CONSTRAINTS, imagecopycapture.FAILURE_REASON_UNKNOWN:
335		return s.roundtrip()
336	}
337	return fmt.Errorf("%w: failure reason %d", errCompositorFailedCapture, f.reason)
338}
339
340// complete hands out a frame the compositor finished.
341func (s *Source) complete(c *session, g bufGeom, f *captureFrame) (ports.Frame, error) {
342	transform := f.transform
343	idx := c.inflightBuf
344	s.destroyFrame(c)
345	if transform != 0 {
346		return ports.Frame{}, fmt.Errorf("%w: output transform %d is not supported", ports.ErrUnsupportedTransform, transform)
347	}
348	c.haveLast, c.cur, c.lastGeom = true, idx, g
349	c.served++
350	c.fails = 0
351	frame, err := s.frameOf(idx, g)
352	// From the second frame on the next capture starts right away, so the
353	// compositor can answer during the wait for the next slot. A one-shot
354	// screenshot never reaches it and allocates a single buffer.
355	if err == nil && c.served > 1 {
356		err = s.startFrame(c, g)
357	}
358	return frame, err
359}
360
361// lastFrame repeats the last completed frame, still held in its own buffer.
362func (s *Source) lastFrame(c *session) (ports.Frame, error) {
363	return s.frameOf(c.cur, c.lastGeom)
364}
365
366// startFrame sends one capture into the buffer the last frame is not in.
367func (s *Source) startFrame(c *session, g bufGeom) error {
368	idx := 0
369	if c.haveLast {
370		idx = 1 - c.cur
371	}
372	if err := s.ensureBuffer(idx, g); err != nil {
373		return err
374	}
375	f, err := newCaptureFrame(c)
376	if err != nil {
377		s.terminate()
378		return fmt.Errorf("wayland: create frame: %w", err)
379	}
380	c.inflight, c.inflightBuf, c.inflightGeom = f, idx, g
381	if err := f.proxy.AttachBuffer(s.bufs[idx].buffer); err != nil {
382		s.terminate()
383		return fmt.Errorf("wayland: attach buffer: %w", err)
384	}
385	if err := f.proxy.DamageBuffer(0, 0, int32(g.width), int32(g.height)); err != nil {
386		s.terminate()
387		return fmt.Errorf("wayland: damage buffer: %w", err)
388	}
389	if err := f.proxy.Capture(); err != nil {
390		s.terminate()
391		return fmt.Errorf("wayland: capture request: %w", err)
392	}
393	return nil
394}
395
396// destroyFrame destroys the in-flight frame object once it ended.
397func (s *Source) destroyFrame(c *session) {
398	if f := c.inflight; f != nil {
399		c.inflight = nil
400		_ = f.proxy.Destroy()
401	}
402}
403
404// stoppedError classifies a stopped session. An output that went away is the
405// usual cause: read its removal first. A workspace session stops when its
406// workspace is removed: that is a plain stop, whatever the session served. Any
407// other session that stopped before serving a frame was refused by the
408// compositor, which the error says.
409func (s *Source) stoppedError(c *session, out *output) error {
410	if err := s.roundtrip(); err != nil {
411		return err
412	}
413	switch {
414	case out.removed:
415		return ports.ErrOutputNotFound
416	case c.key.workspace != 0 && s.ws.byID[c.key.workspace] == nil:
417		return fmt.Errorf("%w: %w", ports.ErrWorkspaceUnavailable, ports.ErrCaptureStopped)
418	case c.served == 0:
419		return errCaptureRefused
420	}
421	return ports.ErrCaptureStopped
422}
423
424// geometry validates the session's current constraints and returns the buffer
425// layout to allocate.
426func (s *Source) geometry(c *session) (bufGeom, error) {
427	k := c.current
428	switch {
429	case !k.sawShm:
430		return bufGeom{}, fmt.Errorf("%w: compositor offers no wl_shm buffer", ports.ErrUnsupportedFormat)
431	case !k.haveFormat:
432		return bufGeom{}, fmt.Errorf("%w: compositor offers shm format %#x", ports.ErrUnsupportedFormat, k.unsupported)
433	case !k.haveSize:
434		return bufGeom{}, errors.New("wayland: compositor sent no buffer size")
435	}
436	g := bufGeom{format: k.format, width: k.width, height: k.height, stride: k.width * ports.BytesPerPixel}
437	if err := g.validate(); err != nil {
438		return bufGeom{}, err
439	}
440	return g, nil
441}
442
443func (s *Source) frameOf(idx int, g bufGeom) (ports.Frame, error) {
444	frame := ports.Frame{
445		Pixels: s.bufs[idx].data[:g.size:g.size],
446		Width:  int(g.width), Height: int(g.height), Stride: int(g.stride),
447		Format: ports.PixelFormat(g.format),
448	}
449	if err := frame.Validate(); err != nil {
450		return ports.Frame{}, err
451	}
452	return frame, nil
453}
454
455// logicalSize is the output's size in logical pixels: xdg-output when the
456// compositor offers it, else the current mode divided by the integer scale.
457func (o *output) logicalSize() (w, h int) {
458	if o.lw > 0 && o.lh > 0 {
459		return int(o.lw), int(o.lh)
460	}
461	return int(o.width) / int(max(o.scale, 1)), int(o.height) / int(max(o.scale, 1))
462}
463
464// cropFrame returns the window of f that covers region r, given in logical
465// pixels of out. The result borrows f's storage: no pixel is copied.
466func cropFrame(f ports.Frame, r ports.Region, out *output) (ports.Frame, error) {
467	lw, lh := out.logicalSize()
468	if lw < 1 || lh < 1 {
469		return ports.Frame{}, fmt.Errorf("%w: output %s has no known logical size", ports.ErrInvalidRegion, outputName(out))
470	}
471	edge := func(v, buf, logical int) int { // round to the nearest buffer pixel
472		return int((int64(v)*int64(buf) + int64(logical)/2) / int64(logical))
473	}
474	x0, x1 := edge(r.X, f.Width, lw), min(edge(r.X+r.Width, f.Width, lw), f.Width)
475	y0, y1 := edge(r.Y, f.Height, lh), min(edge(r.Y+r.Height, f.Height, lh), f.Height)
476	if x0 >= x1 || y0 >= y1 {
477		return ports.Frame{}, fmt.Errorf("%w: %+v is outside output %s", ports.ErrInvalidRegion, r, outputName(out))
478	}
479	w, h := x1-x0, y1-y0
480	start := y0*f.Stride + x0*ports.BytesPerPixel
481	end := start + (h-1)*f.Stride + w*ports.BytesPerPixel
482	cropped := f
483	cropped.Pixels = f.Pixels[start:end:end]
484	cropped.Width, cropped.Height = w, h
485	if err := cropped.Validate(); err != nil {
486		return ports.Frame{}, err
487	}
488	return cropped, nil
489}

Keyboard shortcuts

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

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

Branches 2

Tags 9

↑↓ moveenter openesc close