v0.4.0 b6be3a8

imagecopy.go RawHistoryFolder

1package wayland
2
3import (
4	"errors"
5	"image"
6	"math"
7	"time"
8
9	"github.com/bnema/go-wayland-bindings/server/extforeigntoplevellist"
10	source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
11	ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
12	extworkspace "github.com/bnema/go-wayland-bindings/server/extworkspace"
13	"github.com/bnema/go-wayland-bindings/server/wayland"
14	"github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
15	"github.com/bnema/neferwl/internal/ports"
16	"github.com/bnema/purego-libwayland/server"
17)
18
19// ext-image-copy-capture with three kinds of source: an output (the standard
20// ext-image-capture-source), and the workspace and output-region sources of
21// neferwl_image_capture_source_manager_v1. Every session is registered with
22// core (ports.CaptureSessionOpen), which decides its target, tells when it
23// ends and draws the capture indicator while it lives; see ports/capture.go.
24
25type sourceKind int
26
27const (
28	srcDead      sourceKind = iota // output or workspace gone: sessions stop at once
29	srcOutput                      // a whole output
30	srcRegion                      // a logical rectangle of an output
31	srcWorkspace                   // a workspace frame
32	srcWindow                      // a toplevel, at its own size
33)
34
35// captureSource is the state behind one ext_image_capture_source_v1.
36type captureSource struct {
37	kind      sourceKind
38	output    *output
39	region    image.Rectangle // output-local logical, srcRegion
40	workspace uint64
41	window    ports.WindowID // srcWindow
42	gen       uint32         // srcWindow: its mapping; a remap kills the source
43}
44
45func registerImageCopy(d *server.Display, s *Server) error {
46	if err := source.NewExtOutputImageCaptureSourceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
47		_, _ = source.NewExtOutputImageCaptureSourceManagerV1(c, int32(v), id, outputCaptureSource{s})
48	}); err != nil {
49		return err
50	}
51	if err := imagecapture.NewNeferwlImageCaptureSourceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
52		_, _ = imagecapture.NewNeferwlImageCaptureSourceManagerV1(c, int32(v), id, extraSources{s})
53	}); err != nil {
54		return err
55	}
56	if err := source.NewExtForeignToplevelImageCaptureSourceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
57		_, _ = source.NewExtForeignToplevelImageCaptureSourceManagerV1(c, int32(v), id, toplevelCaptureSource{s})
58	}); err != nil {
59		return err
60	}
61	return ext.NewExtImageCopyCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
62		_, _ = ext.NewExtImageCopyCaptureManagerV1(c, int32(v), id, copyCaptureManager{s})
63	})
64}
65
66type captureSourceHandler struct{}
67
68func (captureSourceHandler) Destroy(*source.ExtImageCaptureSourceV1) {}
69
70// newSource makes the ext_image_capture_source_v1 of a source.
71func (s *Server) newSource(c server.Client, id uint32, src *captureSource) {
72	res, err := source.NewExtImageCaptureSourceV1(c, 1, id, captureSourceHandler{})
73	if err != nil {
74		return
75	}
76	s.captureSources[res.Resource] = src
77	res.OnDestroy = func() { delete(s.captureSources, res.Resource) }
78}
79
80type outputCaptureSource struct{ s *Server }
81
82func (outputCaptureSource) Destroy(*source.ExtOutputImageCaptureSourceManagerV1) {}
83func (m outputCaptureSource) CreateSource(r *source.ExtOutputImageCaptureSourceManagerV1, id uint32, out *wayland.Output) {
84	src := &captureSource{kind: srcDead}
85	if o := m.s.outputOf(out); o != nil {
86		src = &captureSource{kind: srcOutput, output: o}
87	}
88	m.s.newSource(r.Client(), id, src)
89}
90
91// toplevelCaptureSource makes the source of an ext-foreign-toplevel-list
92// handle: that window. A closed or unknown handle gives a dead source.
93type toplevelCaptureSource struct{ s *Server }
94
95func (toplevelCaptureSource) Destroy(*source.ExtForeignToplevelImageCaptureSourceManagerV1) {}
96func (m toplevelCaptureSource) CreateSource(r *source.ExtForeignToplevelImageCaptureSourceManagerV1, id uint32, h *extforeigntoplevellist.ExtForeignToplevelHandleV1) {
97	src := &captureSource{kind: srcDead}
98	if h != nil {
99		if wid, gen, ok := m.s.extHandleWindow(h.Resource); ok {
100			src = &captureSource{kind: srcWindow, window: wid, gen: gen}
101		}
102	}
103	m.s.newSource(r.Client(), id, src)
104}
105
106// windowCaptureGone stops the sessions of a window that unmapped or closed.
107func (s *Server) windowCaptureGone(id ports.WindowID) {
108	for c := range s.captureSessions {
109		if c.src != nil && c.src.kind == srcWindow && c.src.window == id {
110			if c.id != 0 {
111				s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonWindowGone)).Msg("capture session end")
112			}
113			c.end(true, true)
114		}
115	}
116}
117
118type extraSources struct{ s *Server }
119
120func (extraSources) Destroy(*imagecapture.NeferwlImageCaptureSourceManagerV1) {}
121func (m extraSources) CreateWorkspaceSource(r *imagecapture.NeferwlImageCaptureSourceManagerV1, id uint32, ws *extworkspace.ExtWorkspaceHandleV1) {
122	src := &captureSource{kind: srcDead}
123	if ws != nil {
124		if wid, ok := m.s.workspaceIDOf(ws.Resource); ok {
125			src = &captureSource{kind: srcWorkspace, workspace: wid}
126		}
127	}
128	m.s.newSource(r.Client(), id, src)
129}
130func (m extraSources) CreateOutputRegionSource(r *imagecapture.NeferwlImageCaptureSourceManagerV1, id uint32, out *wayland.Output, x, y, w, h int32) {
131	if w <= 0 || h <= 0 {
132		r.PostError(uint32(imagecapture.NeferwlImageCaptureSourceManagerV1ErrorInvalidRegion), "width or height is not positive")
133		return
134	}
135	src := &captureSource{kind: srcDead}
136	if o := m.s.outputOf(out); o != nil {
137		src = &captureSource{kind: srcRegion, output: o, region: image.Rect(int(x), int(y), int64Clamp(int64(x)+int64(w)), int64Clamp(int64(y)+int64(h)))}
138	}
139	m.s.newSource(r.Client(), id, src)
140}
141
142func (m extraSources) GetWorkspaceFrame(r *imagecapture.NeferwlImageCaptureSourceManagerV1, id uint32, ws *extworkspace.ExtWorkspaceHandleV1) {
143	m.s.newWorkspaceFrame(r.Client(), id, ws)
144}
145
146// workspaceIDOf is the workspace behind a handle of any ext-workspace manager.
147func (s *Server) workspaceIDOf(res *server.Resource) (uint64, bool) {
148	for _, m := range s.workspaceManagers {
149		for id, h := range m.handles {
150			if h.res.Resource == res {
151				return id, true
152			}
153		}
154	}
155	return 0, false
156}
157
158// workspaceInfo finds a workspace of the last snapshot core sent.
159func (s *Server) workspaceInfo(id uint64) (output string, w ports.WorkspaceInfo, ok bool) {
160	for _, out := range s.workspaceSnapshot.Outputs {
161		for _, w := range out.Workspaces {
162			if w.ID == id {
163				return out.Name, w, true
164			}
165		}
166	}
167	return "", ports.WorkspaceInfo{}, false
168}
169
170type copyCaptureManager struct{ s *Server }
171
172func (copyCaptureManager) Destroy(*ext.ExtImageCopyCaptureManagerV1) {}
173func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, id uint32, src *source.ExtImageCaptureSourceV1, options uint32) {
174	s := m.s
175	if options&^1 != 0 {
176		r.PostError(uint32(ext.ExtImageCopyCaptureManagerV1ErrorInvalidOption), "invalid capture option")
177		return
178	}
179	var cs *captureSource
180	if src != nil {
181		cs = s.captureSources[src.Resource]
182	}
183	state := &captureSession{s: s, src: cs, cursor: options&1 != 0}
184	res, err := ext.NewExtImageCopyCaptureSessionV1(r.Client(), 1, id, state)
185	if err != nil {
186		return
187	}
188	state.res = res
189	s.captureSessions[state] = struct{}{}
190	// Frames outlive their session (the spec): each frame drops its own reply.
191	res.OnDestroy = func() { state.end(false, true) }
192	if cs == nil || cs.kind == srcDead || !s.mayCapture(r.Client()) {
193		state.end(true, false)
194		return
195	}
196	if _, _, _, ok := state.geometry(); !ok {
197		state.end(true, false)
198		return
199	}
200	s.nextSession++
201	state.id = s.nextSession
202	s.sessionsByID[state.id] = state
203	open := ports.CaptureSessionOpen{ID: state.id}
204	switch cs.kind {
205	case srcOutput:
206		open.Output = cs.output.name()
207	case srcRegion:
208		open.Output, open.Region = cs.output.name(), ports.Rect{X: cs.region.Min.X, Y: cs.region.Min.Y, W: cs.region.Dx(), H: cs.region.Dy()}
209	case srcWorkspace:
210		open.Workspace = cs.workspace
211	case srcWindow:
212		open.Window = cs.window
213	}
214	s.emit(open)
215	state.sendConstraints()
216}
217
218func (m copyCaptureManager) CreatePointerCursorSession(r *ext.ExtImageCopyCaptureManagerV1, id uint32, _ *source.ExtImageCaptureSourceV1, _ *wayland.Pointer) {
219	state := &cursorCaptureSession{}
220	res, err := ext.NewExtImageCopyCaptureCursorSessionV1(r.Client(), 1, id, state)
221	if err == nil {
222		state.res = res
223	}
224}
225
226type cursorCaptureSession struct {
227	res  *ext.ExtImageCopyCaptureCursorSessionV1
228	used bool
229}
230
231func (*cursorCaptureSession) Destroy(*ext.ExtImageCopyCaptureCursorSessionV1) {}
232func (c *cursorCaptureSession) GetCaptureSession(_ *ext.ExtImageCopyCaptureCursorSessionV1, id uint32) {
233	if c.used {
234		c.res.PostError(uint32(ext.ExtImageCopyCaptureCursorSessionV1ErrorDuplicateSession), "session already created")
235		return
236	}
237	c.used = true
238	state := &captureSession{stopped: true}
239	res, err := ext.NewExtImageCopyCaptureSessionV1(c.res.Client(), 1, id, state)
240	if err == nil {
241		state.res = res
242		res.SendStopped()
243	}
244}
245
246// captureSession is one ext_image_copy_capture_session_v1.
247type captureSession struct {
248	s               *Server
249	res             *ext.ExtImageCopyCaptureSessionV1
250	src             *captureSource
251	id              uint64 // registered with core; 0 when never
252	cursor, stopped bool
253	frame           *captureExtFrame
254	sent            image.Point // buffer size of the last constraints
255	// st is the last decision of core; haveState tells there is one.
256	st        ports.CaptureSessionState
257	haveState bool
258	excl      *exclusion // the live exclusion object of this session
259	// exclSeen: the last state of core knew an exclusion. Frames of an
260	// excluded session leave the HUD out while it is (see tag).
261	exclSeen bool
262}
263
264func (*captureSession) Destroy(*ext.ExtImageCopyCaptureSessionV1) {}
265
266// end stops the session once. send tells the client (stopped), notify core
267// (which has to forget it unless it decided the end itself).
268func (c *captureSession) end(send, notify bool) {
269	if c.stopped {
270		return
271	}
272	c.stopped = true
273	if send && c.res.Alive() {
274		c.res.SendStopped()
275	}
276	if c.excl != nil {
277		c.excl.end(false)
278	}
279	if c.id != 0 {
280		delete(c.s.sessionsByID, c.id)
281		if notify {
282			c.s.emit(ports.CaptureSessionClose{ID: c.id})
283		}
284	}
285	delete(c.s.captureSessions, c)
286}
287
288// geometry locates the session's frames now: the output, the physical region
289// of the output they cover (for a workspace that is not on screen, the whole
290// image of its off-screen render, from the origin) and whether the workspace
291// is off screen. Core's last state decides where a workspace is; before it,
292// the last workspace snapshot does.
293func (c *captureSession) geometry() (o *output, rect image.Rectangle, hidden, ok bool) {
294	src := c.src
295	if src == nil {
296		return nil, image.Rectangle{}, false, false
297	}
298	switch src.kind {
299	case srcOutput:
300		o = src.output
301		if o == nil || c.s.outputByNameExact(o.name()) != o {
302			return nil, image.Rectangle{}, false, false
303		}
304		return o, image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height), false, true
305	case srcRegion:
306		o = src.output
307		if o == nil || c.s.outputByNameExact(o.name()) != o {
308			return nil, image.Rectangle{}, false, false
309		}
310		rect = captureRegion(o, src.region)
311		return o, rect, false, !rect.Empty()
312	case srcWorkspace:
313		var name string
314		var frame ports.Rect
315		if c.haveState && c.st.Reason == ports.CaptureReasonNone {
316			name, frame, hidden = c.st.Output, c.st.Rect, c.st.Hidden
317		} else {
318			out, w, found := c.s.workspaceInfo(src.workspace)
319			if !found {
320				return nil, image.Rectangle{}, false, false
321			}
322			name, frame, hidden = out, w.Frame, !w.Active
323		}
324		o = c.s.outputByNameExact(name)
325		if o == nil || frame.W <= 0 || frame.H <= 0 {
326			return nil, image.Rectangle{}, false, false
327		}
328		if hidden {
329			scale := o.place.Scale
330			if scale <= 0 {
331				scale = 1
332			}
333			// The size of the off-screen child image (capture.childSize).
334			return o, image.Rect(0, 0, int(math.Ceil(float64(frame.W)*scale)), int(math.Ceil(float64(frame.H)*scale))), true, true
335		}
336		rect = captureRegion(o, image.Rect(frame.X, frame.Y, frame.X+frame.W, frame.Y+frame.H))
337		return o, rect, false, !rect.Empty()
338	case srcWindow:
339		// Core's state gives the size; before it, the last configure.
340		w := c.s.windows[src.window]
341		if w == nil || !w.mapped || w.toplevel == nil || w.gen != src.gen {
342			return nil, image.Rectangle{}, false, false
343		}
344		name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}
345		if c.haveState && c.st.Reason == ports.CaptureReasonNone {
346			name, size = c.st.Output, c.st.Rect
347		}
348		if size.W <= 0 || size.H <= 0 {
349			if gw, gh := w.size(); gw > 0 && gh > 0 {
350				size = ports.Rect{W: gw, H: gh}
351			}
352		}
353		o = c.s.outputByNameExact(name)
354		if o == nil || size.W <= 0 || size.H <= 0 {
355			return nil, image.Rectangle{}, false, false
356		}
357		scale := o.place.Scale
358		if scale <= 0 {
359			scale = 1
360		}
361		// The size of the off-screen child image (capture.childSize).
362		return o, image.Rect(0, 0, int(math.Ceil(float64(size.W)*scale)), int(math.Ceil(float64(size.H)*scale))), true, true
363	}
364	return nil, image.Rectangle{}, false, false
365}
366
367// sendConstraints sends one complete constraint batch: size, formats, done.
368func (c *captureSession) sendConstraints() {
369	_, rect, _, ok := c.geometry()
370	if !ok || !c.res.Alive() || c.s.protected() {
371		return
372	}
373	c.sent = image.Pt(rect.Dx(), rect.Dy())
374	c.res.SendBufferSize(uint32(rect.Dx()), uint32(rect.Dy()))
375	c.res.SendShmFormat(uint32(wayland.ShmFormatXrgb8888))
376	c.res.SendShmFormat(uint32(wayland.ShmFormatArgb8888))
377	c.res.SendDone()
378}
379
380// refresh follows the target: new constraints when the buffer size changed,
381// the end when nothing is left of it.
382func (c *captureSession) refresh() {
383	if c.stopped || c.id == 0 {
384		return
385	}
386	if c.src.kind == srcWorkspace {
387		// The workspace left the snapshot: its handle is removed, stop at once
388		// without waiting for core's state.
389		if _, _, found := c.s.workspaceInfo(c.src.workspace); !found {
390			c.s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonWorkspaceGone)).Msg("capture session end")
391			c.end(true, true)
392			return
393		}
394	}
395	_, rect, _, ok := c.geometry()
396	switch {
397	case !ok && c.src.kind != srcWorkspace && c.src.kind != srcWindow:
398		c.s.log.Info().Uint64("session", c.id).Msg("capture session end: target empty")
399		c.end(true, true)
400	case ok && image.Pt(rect.Dx(), rect.Dy()) != c.sent:
401		c.sendConstraints()
402	}
403}
404
405// refreshSessions runs when outputs, scales or workspaces changed.
406func (s *Server) refreshSessions() {
407	for c := range s.captureSessions {
408		c.refresh()
409	}
410}
411
412// endCaptureSessions stops every session, and its exclusion with it, when
413// the session gets protected: a capture session never outlives the lock, and
414// its client must ask for a new one after the unlock.
415func (s *Server) endCaptureSessions() {
416	for c := range s.captureSessions {
417		if c.id != 0 {
418			s.log.Info().Uint64("session", c.id).Str("reason", "session-protected").Msg("capture session end")
419		}
420		c.end(true, true)
421	}
422}
423
424// outputGone ends the sessions of an unplugged output without waiting for
425// core. Workspace sessions follow their workspace to its new output.
426func (s *Server) outputGone(o *output) {
427	for c := range s.captureSessions {
428		if c.src != nil && c.src.output == o && (c.src.kind == srcOutput || c.src.kind == srcRegion) {
429			s.log.Info().Uint64("session", c.id).Str("reason", string(ports.CaptureReasonOutputGone)).Msg("capture session end")
430			c.end(true, true)
431		}
432	}
433}
434
435// captureState applies what core decided about a session.
436func (s *Server) captureState(st ports.CaptureSessionState) {
437	c := s.sessionsByID[st.ID]
438	if c == nil {
439		return
440	}
441	if st.Reason.Terminal() {
442		s.log.Info().Uint64("session", c.id).Str("reason", string(st.Reason)).Msg("capture session end")
443		c.end(true, false)
444		return
445	}
446	c.st, c.haveState, c.exclSeen = st, true, st.Exclusion
447	s.log.Debug().Uint64("session", c.id).Bool("active", st.Active).Bool("hidden", st.Hidden).Uint64("revision", st.Revision).Msg("capture session state")
448	if ex := c.excl; ex != nil {
449		ex.confirm(st.Layers)
450	}
451	c.refresh()
452}
453
454// tag says how a frame of this session is served. A session with an
455// exclusion gets frames without its HUD, fenced by the revision core last
456// confirmed; until core knows the exclusion, and while a finished one is
457// still in the last state, a frame is not served rather than shown with the
458// HUD in it (ok false).
459func (c *captureSession) tag(hidden bool) (t captureTag, ok bool) {
460	t = captureTag{session: c.id}
461	switch c.src.kind {
462	case srcWorkspace:
463		t.workspace, t.offscreen = c.src.workspace, hidden
464	case srcWindow:
465		t.window, t.offscreen = c.src.window, true
466	}
467	switch c.src.kind {
468	case srcOutput:
469		t.taken = ports.CaptureFrameTaken{Output: c.src.output.name()}
470	case srcRegion:
471		r := c.src.region
472		t.taken = ports.CaptureFrameTaken{Output: c.src.output.name(), Region: ports.Rect{X: r.Min.X, Y: r.Min.Y, W: r.Dx(), H: r.Dy()}}
473	case srcWorkspace:
474		t.taken = ports.CaptureFrameTaken{Workspace: c.src.workspace}
475	case srcWindow:
476		t.taken = ports.CaptureFrameTaken{Window: c.src.window}
477	}
478	if c.excl != nil && !c.exclSeen {
479		return t, false
480	}
481	if c.exclSeen {
482		t.exclude, t.revision = true, c.st.Revision
483	}
484	return t, true
485}
486
487func (c *captureSession) CreateFrame(r *ext.ExtImageCopyCaptureSessionV1, id uint32) {
488	if c.frame != nil {
489		r.PostError(uint32(ext.ExtImageCopyCaptureSessionV1ErrorDuplicateFrame), "frame still alive")
490		return
491	}
492	state := &captureExtFrame{session: c}
493	res, err := ext.NewExtImageCopyCaptureFrameV1(r.Client(), 1, id, state)
494	if err == nil {
495		state.res = res
496		c.frame = state
497		res.OnDestroy = func() {
498			if c.frame == state {
499				c.frame = nil
500			}
501			if c.s != nil {
502				delete(c.s.captureReplies, state.replyID)
503			}
504		}
505	}
506}
507
508type captureExtFrame struct {
509	session *captureSession
510	res     *ext.ExtImageCopyCaptureFrameV1
511	buf     *wayland.Buffer
512	used    bool
513	replyID uint64
514	// deadline ends the retries of a frame whose target is not ready yet.
515	deadline time.Time
516}
517
518// A frame whose target is briefly unavailable (not active yet, rendered off
519// screen by another session, its indicator or exclusion not on screen yet,
520// a renderer slot busy) is tried again rather than failed: failed(unknown)
521// ends the whole screencast in clients such as xdg-desktop-portal-wlr. Only
522// a stopped session, a refused client or a wrong buffer fail at once.
523const (
524	captureRetryEvery = 16 * time.Millisecond
525	captureRetryFor   = time.Second
526)
527
528func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}
529func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {
530	if f.used {
531		f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
532		return
533	}
534	f.buf = b
535}
536func (f *captureExtFrame) DamageBuffer(_ *ext.ExtImageCopyCaptureFrameV1, x, y, w, h int32) {
537	if f.used {
538		f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
539		return
540	}
541	if x < 0 || y < 0 || w <= 0 || h <= 0 {
542		f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorInvalidBufferDamage), "invalid damage")
543	}
544}
545
546// constraintsFailed fails the frame with buffer_constraints. The buffer was
547// checked against the geometry computed now; when that differs from the last
548// constraints the client got (the target moved or rescaled and the refresh has
549// not run yet), a new batch goes first so the client can retry with the size
550// the compositor expects.
551func (f *captureExtFrame) constraintsFailed(region image.Rectangle) {
552	if c := f.session; image.Pt(region.Dx(), region.Dy()) != c.sent {
553		c.sendConstraints()
554	}
555	f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints))
556}
557
558func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
559	if f.used {
560		f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
561		return
562	}
563	f.used = true
564	c := f.session
565	if c.stopped || c.src == nil || !c.s.mayCaptureFrame(f.res.Client()) {
566		f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
567		return
568	}
569	if f.buf == nil {
570		f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")
571		return
572	}
573	f.deadline = c.s.clock.Now().Add(captureRetryFor)
574	f.attempt()
575}
576
577// retry tries the frame again shortly, or fails it past its deadline.
578func (f *captureExtFrame) retry(why string) {
579	s := f.session.s
580	if !s.clock.Now().Before(f.deadline) {
581		s.log.Info().Uint64("session", f.session.id).Str("reason", why).Msg("capture frame failed after retries")
582		f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
583		return
584	}
585	s.clock.AfterFunc(captureRetryEvery, func() { s.display.Do(f.attempt) })
586}
587
588// attempt serves the frame from the current target. Display goroutine only.
589func (f *captureExtFrame) attempt() {
590	if !f.res.Alive() {
591		return
592	}
593	c := f.session
594	if c.stopped || !c.s.mayCaptureFrame(f.res.Client()) {
595		f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
596		return
597	}
598	o, region, hidden, ok := c.geometry()
599	if !ok || c.haveState && !c.st.Active {
600		f.retry("target not ready")
601		return
602	}
603	tag, ok := c.tag(hidden)
604	// On-screen frames are target pixels, which the output holds under its
605	// transform; off-screen child images are unrotated.
606	transform := uint32(0)
607	if !hidden {
608		transform = uint32(o.place.Transform)
609	}
610	buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer)
611	switch {
612	case !isBuf:
613		f.constraintsFailed(region)
614		return
615	case !ok:
616		// The exclusion is not confirmed by core yet: nothing is served.
617		f.retry("exclusion not confirmed")
618		return
619	}
620	f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {
621		switch {
622		case errors.Is(done.Err, ports.ErrCaptureTransient):
623			f.retry(done.Err.Error())
624			return
625		case done.Err != nil:
626			// Retrying cannot clear it (a bad buffer, an unsupported size).
627			f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
628			return
629		}
630		f.res.SendTransform(transform)
631		f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))
632		hi, lo, ns := captureTime(done.Time)
633		f.res.SendPresentationTime(hi, lo, ns)
634		f.res.SendReady()
635	}, f.res.Resource, tag)
636	if !ok {
637		switch {
638		case !c.s.validCaptureBuffer(f.buf, region, buf.format):
639			f.constraintsFailed(region)
640		case !c.s.mayCaptureFrame(f.res.Client()):
641			f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
642		default:
643			// The renderer queue or the in-flight bound is full.
644			f.retry("capture queue full")
645		}
646	}
647}

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