v0.2.1 7e79979

extensions.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"sync"
8	"time"
9
10	"github.com/bnema/wlturbo"
11	"github.com/bnema/wlturbo/protocol/core"
12	"github.com/bnema/wlturbo/protocol/imagecapturesource"
13	"github.com/bnema/wlturbo/protocol/xdgoutput"
14
15	"github.com/bnema/nefercap/internal/ports"
16)
17
18// NeferWL's optional extension to ext-image-copy-capture-v1, see
19// neferwl-image-capture-v1.xml in the NeferWL repository. Every part is
20// optional: without it, capture still works through the standard protocols.
21//
22//	neferwl_image_capture_source_manager_v1
23//	  requests: 0 destroy, 1 create_workspace_source(new_id, workspace),
24//	            2 create_output_region_source(new_id, output, x, y, w, h),
25//	            3 get_workspace_frame(new_id, workspace)
26//	neferwl_workspace_frame_v1
27//	  requests: 0 destroy   events: 0 frame(x, y, width, height)
28//	neferwl_capture_exclusion_manager_v1
29//	  requests: 0 destroy, 1 get_exclusion(new_id, session),
30//	            2 attach_surface(new_id, token, surface)
31//	neferwl_capture_exclusion_v1
32//	  requests: 0 destroy   events: 0 token(string), 1 failed(reason)
33//	neferwl_capture_layer_v1
34//	  requests: 0 destroy   events: 0 attached, 1 failed(reason), 2 detached(reason)
35
36const (
37	sourceManagerIface    = "neferwl_image_capture_source_manager_v1"
38	exclusionManagerIface = "neferwl_capture_exclusion_manager_v1"
39	extensionVersion      = 1
40	tokenLen              = 64 // lowercase hexadecimal characters of 32 random bytes
41
42	reqNeferwlWorkspaceSource = 1
43	reqNeferwlRegionSource    = 2
44	reqGetWorkspaceFrame      = 3
45	reqWorkspaceFrameDestroy  = 0
46	reqGetExclusion           = 1
47	reqAttachSurface          = 2
48	reqManagerDestroy         = 0
49	reqExclusionDestroy       = 0
50	reqLayerDestroy           = 0
51
52	// DefaultAttachTimeout bounds AuthorizeLayer.
53	DefaultAttachTimeout = 5 * time.Second
54)
55
56var (
57	// ErrExclusionUnsupported means the compositor cannot leave the client's
58	// own surfaces out of its captures.
59	ErrExclusionUnsupported = errors.New("wayland: capture exclusion unsupported")
60	// ErrExclusionUnavailable means the exclusion is gone, refused or never
61	// existed. Callers that rely on it must stop recording.
62	ErrExclusionUnavailable = errors.New("wayland: capture exclusion unavailable")
63	// ErrExclusionBusy is an ErrExclusionUnavailable: another exclusion is live
64	// in the compositor.
65	ErrExclusionBusy = fmt.Errorf("%w: another exclusion is live", ErrExclusionUnavailable)
66	// ErrAttachFailed is matched by every AttachFailedError.
67	ErrAttachFailed = errors.New("wayland: attach layer failed")
68	// ErrUnauthorized means the compositor refused the peer.
69	ErrUnauthorized = errors.New("wayland: capture exclusion peer unauthorized")
70	// errLayerProtocol is a violation of the layer attachment state machine.
71	errLayerProtocol = fmt.Errorf("%w: capture layer protocol violation", ErrExclusionUnavailable)
72)
73
74// neferwl_capture_exclusion_v1.failure
75const (
76	exclusionBusy           = 0
77	exclusionSessionStopped = 1
78)
79
80// requestOnly is a bound NeferWL global or created object that never sends
81// events: wlturbo has no generated binding for the extension.
82type requestOnly struct{ wlturbo.BaseProxy }
83
84func (*requestOnly) EventSignature(uint16) (string, bool) { return "", false }
85
86func newRequestOnly(ctx *wlturbo.Context) *requestOnly {
87	p := &requestOnly{}
88	p.SetContext(ctx)
89	return p
90}
91
92// extensions holds the bound optional NeferWL globals.
93type extensions struct {
94	source    *requestOnly
95	exclusion *requestOnly
96}
97
98// bindExtras binds every optional global. None is an error when absent.
99func (s *Source) bindExtras() error {
100	x := xdgoutput.NewZxdgOutputManager(s.wl)
101	switch ok, err := s.bindOptional(xdgoutput.ZxdgOutputManagerInterface, xdgOutputVersion, x); {
102	case err != nil:
103		return err
104	case ok:
105		s.xdgOutputs = x
106	}
107	for _, o := range s.outputs {
108		s.watchLogicalSize(o)
109	}
110	var err error
111	if s.ext.source, err = s.bindOptionalExtension(sourceManagerIface); err != nil {
112		return err
113	}
114	if s.ext.exclusion, err = s.bindOptionalExtension(exclusionManagerIface); err != nil {
115		return err
116	}
117	return s.bindWorkspaces()
118}
119
120// bindOptionalExtension binds a NeferWL global, nil when the compositor has
121// none.
122func (s *Source) bindOptionalExtension(iface string) (*requestOnly, error) {
123	p := newRequestOnly(s.wl)
124	if ok, err := s.bindOptional(iface, extensionVersion, p); err != nil || !ok {
125		return nil, err
126	}
127	return p, nil
128}
129
130// createWorkspaceSource makes a source that follows the workspace id.
131func (s *Source) createWorkspaceSource(id uint64) (*imagecapturesource.ExtImageCaptureSource, error) {
132	h := s.ws.byID[id]
133	if h == nil || h.removed {
134		return nil, ports.ErrWorkspaceUnavailable
135	}
136	src := imagecapturesource.NewExtImageCaptureSource(s.wl)
137	err := s.wl.Request(wlturbo.Request{Proxy: s.ext.source, Opcode: reqNeferwlWorkspaceSource, Name: "neferwl_image_capture_source_manager_v1.create_workspace_source", Child: src}, src, h.proxy)
138	return src, err
139}
140
141// createRegionSource makes a source of a region of out, in logical pixels.
142func (s *Source) createRegionSource(out *output, r ports.Region) (*imagecapturesource.ExtImageCaptureSource, error) {
143	src := imagecapturesource.NewExtImageCaptureSource(s.wl)
144	err := s.wl.Request(wlturbo.Request{Proxy: s.ext.source, Opcode: reqNeferwlRegionSource, Name: "neferwl_image_capture_source_manager_v1.create_output_region_source", Child: src},
145		src, out.proxy, int32(r.X), int32(r.Y), int32(r.Width), int32(r.Height))
146	return src, err
147}
148
149// workspaceFrame is one neferwl_workspace_frame_v1: the frame of a workspace
150// in output-local logical pixels. It is read under opMu, with every other
151// state of the connection, so it needs no lock of its own.
152type workspaceFrame struct {
153	wlturbo.BaseProxy
154	x, y, w, h int32
155	known      bool
156}
157
158func (*workspaceFrame) EventSignature(op uint16) (string, bool) {
159	if op == 0 {
160		return "int,int,int,int,", true
161	}
162	return "", false
163}
164
165func (f *workspaceFrame) Dispatch(ev *wlturbo.Event) {
166	if ev.Opcode == 0 {
167		f.x, f.y, f.w, f.h = ev.Int32(), ev.Int32(), ev.Int32(), ev.Int32()
168		f.known = true
169	}
170}
171
172// region is the frame when it is a sane rectangle.
173func (f *workspaceFrame) region() (ports.Region, bool) {
174	if f == nil || !f.known || f.w < 1 || f.h < 1 || f.w > ports.MaxDimension || f.h > ports.MaxDimension ||
175		f.x < -ports.MaxDimension || f.x > ports.MaxDimension || f.y < -ports.MaxDimension || f.y > ports.MaxDimension {
176		return ports.Region{}, false
177	}
178	return ports.Region{X: int(f.x), Y: int(f.y), Width: int(f.w), Height: int(f.h)}, true
179}
180
181// requestFrames asks for the frame of every workspace that has none yet and
182// reports whether it asked for any. It does nothing without the extension.
183func (s *Source) requestFrames() (bool, error) {
184	if s.ext.source == nil {
185		return false, nil
186	}
187	asked := false
188	for _, w := range s.ws.byID {
189		if w.frame != nil || w.removed {
190			continue
191		}
192		f := &workspaceFrame{}
193		f.SetContext(s.wl)
194		if err := s.wl.Request(wlturbo.Request{Proxy: s.ext.source, Opcode: reqGetWorkspaceFrame, Name: "neferwl_image_capture_source_manager_v1.get_workspace_frame", Child: f}, f, w.proxy); err != nil {
195			s.terminate()
196			return false, fmt.Errorf("wayland: get workspace frame: %w", err)
197		}
198		w.frame, asked = f, true
199	}
200	return asked, nil
201}
202
203// releaseWorkspaceFrame releases the frame object of a workspace that is gone.
204func (s *Source) releaseWorkspaceFrame(w *workspace) {
205	if w.frame == nil {
206		return
207	}
208	_ = s.wl.Request(wlturbo.Request{Proxy: w.frame, Opcode: reqWorkspaceFrameDestroy, Name: "neferwl_workspace_frame_v1.destroy", Destructor: true})
209	w.frame = nil
210}
211
212// exclusion is one neferwl_capture_exclusion_v1: it answers with a token or a
213// failure, and ends with its capture session.
214type exclusion struct {
215	wlturbo.BaseProxy
216	token  string
217	failed bool
218	reason uint32
219}
220
221func (*exclusion) EventSignature(op uint16) (string, bool) {
222	switch op {
223	case 0:
224		return "string,", true
225	case 1:
226		return "uint,", true
227	}
228	return "", false
229}
230
231func (e *exclusion) Dispatch(ev *wlturbo.Event) {
232	switch ev.Opcode {
233	case 0:
234		if tok := ev.String(); validToken(tok) && !e.failed && e.token == "" {
235			e.token = tok
236		} else {
237			e.failed, e.reason = true, ^uint32(0)
238		}
239	case 1:
240		if e.token == "" && !e.failed {
241			e.failed, e.reason = true, ev.Uint32()
242		}
243	}
244}
245
246func (e *exclusion) destroy(s *Source) {
247	_ = s.wl.Request(wlturbo.Request{Proxy: e, Opcode: reqExclusionDestroy, Name: "neferwl_capture_exclusion_v1.destroy", Destructor: true})
248}
249
250// BeginExclusion asks the compositor to leave the client's own layer surfaces
251// out of the frames of the session that captures t, and returns the token a
252// HUD on another connection passes to AuthorizeLayer. The token is a secret:
253// never log it. The exclusion ends with the session, which later Capture
254// calls on the same target keep using; capturing another target replaces the
255// session and ends the exclusion.
256func (s *Source) BeginExclusion(ctx context.Context, t ports.Target) (string, error) {
257	if err := validRegion(t.Region); err != nil {
258		return "", err
259	}
260	s.opMu.Lock()
261	defer s.opMu.Unlock()
262	if err := s.begin(ctx); err != nil {
263		return "", err
264	}
265	token, err := s.beginExclusion(t)
266	if err = s.finish(ctx, err); err != nil {
267		return "", err
268	}
269	return token, nil
270}
271
272func (s *Source) beginExclusion(t ports.Target) (string, error) {
273	if s.ext.exclusion == nil {
274		return "", ErrExclusionUnsupported
275	}
276	if err := s.settle(); err != nil {
277		return "", err
278	}
279	out := s.outputs[t.OutputID]
280	if out == nil {
281		return "", ports.ErrOutputNotFound
282	}
283	key, _, err := s.resolve(t, out)
284	if err != nil {
285		return "", err
286	}
287	c, err := s.sessionFor(key, out)
288	if err != nil {
289		return "", err
290	}
291	if c.exclusion != nil {
292		return "", errors.New("wayland: the capture session already has an exclusion")
293	}
294	e := &exclusion{}
295	e.SetContext(s.wl)
296	if err := s.wl.Request(wlturbo.Request{Proxy: s.ext.exclusion, Opcode: reqGetExclusion, Name: "neferwl_capture_exclusion_manager_v1.get_exclusion", Child: e}, e, c.proxy); err != nil {
297		s.terminate()
298		return "", fmt.Errorf("wayland: get exclusion: %w", err)
299	}
300	for e.token == "" && !e.failed && !c.stopped {
301		if err := s.dispatch(); err != nil {
302			return "", err
303		}
304	}
305	if e.token != "" {
306		c.exclusion = e // only a live exclusion makes a stopped session sticky
307		return e.token, nil
308	}
309	// Every other outcome (refused, invalid token, session stopped first)
310	// leaves no exclusion behind.
311	e.destroy(s)
312	switch {
313	case c.stopped || (e.failed && e.reason == exclusionSessionStopped):
314		return "", s.stoppedError(c, out)
315	case e.failed && e.reason == exclusionBusy:
316		return "", ErrExclusionBusy
317	default:
318		return "", fmt.Errorf("%w: refused (reason %d)", ErrExclusionUnavailable, e.reason)
319	}
320}
321
322// AttachFailure is neferwl_capture_layer_v1.failure.
323type AttachFailure uint32
324
325const (
326	AttachUnknownToken    AttachFailure = 0
327	AttachUnauthorized    AttachFailure = 1
328	AttachTooManyLayers   AttachFailure = 2
329	AttachAlreadyAttached AttachFailure = 3
330	AttachExclusionEnded  AttachFailure = 4
331	AttachLayerDestroyed  AttachFailure = 5
332)
333
334// AttachFailedError is the compositor's refusal of AuthorizeLayer. It matches
335// ErrAttachFailed; a missing or ended exclusion also matches
336// ErrExclusionUnavailable, and an unauthorized peer ErrUnauthorized.
337type AttachFailedError struct{ Reason AttachFailure }
338
339func (e *AttachFailedError) Error() string {
340	return fmt.Sprintf("%v (reason %d)", ErrAttachFailed, e.Reason)
341}
342
343func (e *AttachFailedError) Is(target error) bool {
344	switch target {
345	case ErrAttachFailed:
346		return true
347	case ErrExclusionUnavailable:
348		return e.Reason == AttachUnknownToken || e.Reason == AttachExclusionEnded
349	case ErrUnauthorized:
350		return e.Reason == AttachUnauthorized
351	}
352	return false
353}
354
355// DetachReason is neferwl_capture_layer_v1.detach_reason.
356type DetachReason uint32
357
358const (
359	// DetachExclusionEnded: the exclusion ended and the compositor closed the
360	// layer surface, which the client must destroy.
361	DetachExclusionEnded DetachReason = 0
362	// DetachLayerDestroyed: the layer role was destroyed.
363	DetachLayerDestroyed DetachReason = 1
364)
365
366// LayerDetachedError is what onDetached receives. An exclusion end also
367// matches ErrExclusionUnavailable.
368type LayerDetachedError struct{ Reason DetachReason }
369
370func (e *LayerDetachedError) Error() string {
371	return fmt.Sprintf("wayland: layer detached from the capture exclusion (reason %d)", e.Reason)
372}
373
374func (e *LayerDetachedError) Is(target error) bool {
375	return target == ErrExclusionUnavailable && e.Reason == DetachExclusionEnded
376}
377
378// layerProxy is one neferwl_capture_layer_v1 attachment. Its events are
379// dispatched by whichever goroutine reads the GUI connection: during
380// authorizeLayer that is the caller, afterwards the connection's reader.
381//
382// State machine: pending -> attached | failed; attached -> detached. Any other
383// event is a protocol violation: the exclusion cannot be trusted any more.
384type layerProxy struct {
385	wlturbo.BaseProxy
386	display    *wlturbo.Display
387	onDetached func(error)
388
389	mu         sync.Mutex
390	state      layerState
391	failReason uint32
392	violation  bool
393	returned   bool // authorizeLayer handed the surface to its caller
394	notified   bool
395}
396
397type layerState int
398
399const (
400	layerPending layerState = iota
401	layerAttached
402	layerFailed
403	layerDetached
404)
405
406func (*layerProxy) EventSignature(op uint16) (string, bool) {
407	switch op {
408	case 0:
409		return "", true
410	case 1, 2:
411		return "uint,", true
412	}
413	return "", false
414}
415
416func (l *layerProxy) Dispatch(e *wlturbo.Event) {
417	var arg uint32
418	if e.Opcode != 0 {
419		arg = e.Uint32()
420	}
421	var notify error
422	closeDisplay := false
423	l.mu.Lock()
424	returned := l.returned
425	switch {
426	case l.violation:
427		// Terminal: the display is closed already.
428	case e.Opcode == 0 && l.state == layerPending:
429		l.state = layerAttached
430	case e.Opcode == 1 && l.state == layerPending:
431		l.state, l.failReason = layerFailed, arg
432	case e.Opcode == 2 && l.state == layerAttached:
433		l.state = layerDetached
434		if returned {
435			l.notified, notify = true, &LayerDetachedError{Reason: DetachReason(arg)}
436		}
437	default:
438		l.violation, closeDisplay = true, true
439		if returned && !l.notified {
440			l.notified, notify = true, errLayerProtocol
441		}
442	}
443	l.mu.Unlock()
444	if closeDisplay && returned {
445		_ = l.display.Close() // the surface could be visible in captures
446	}
447	if notify != nil && l.onDetached != nil {
448		l.onDetached(notify)
449	}
450}
451
452// settled reports that the answer arrived, or the layer misbehaved.
453func (l *layerProxy) settled() bool {
454	l.mu.Lock()
455	defer l.mu.Unlock()
456	return l.state != layerPending || l.violation
457}
458
459// hand marks the layer as owned by the caller of authorizeLayer, and reports
460// its outcome so far.
461func (l *layerProxy) hand() (state layerState, reason uint32, violation bool) {
462	l.mu.Lock()
463	defer l.mu.Unlock()
464	l.returned = true
465	return l.state, l.failReason, l.violation
466}
467
468// AuthorizeLayer tells the compositor, from a connection other than the
469// capture connection, that surface is a layer surface of the capturing peer
470// and must be excluded from the frames of the session that owns token (from
471// Source.BeginExclusion). Call it after the layer-shell role is assigned and
472// before the surface is mapped; when it returns nil the compositor has
473// confirmed the exclusion for this very attachment, so the surface is safe to
474// show.
475//
476// AuthorizeLayer dispatches display's events itself until the attachment
477// answers, so it must run before the connection's event loop starts. A
478// refusal returns an *AttachFailedError. A protocol error is returned as the
479// display's error. A cancelled ctx, or no answer within DefaultAttachTimeout
480// (an error wrapping context.DeadlineExceeded), closes display before
481// returning: the surface must then stay unmapped. So does a violation of the
482// attachment protocol (an answer twice, a detach before the answer, ...).
483//
484// After it returns nil the attachment stays until the exclusion ends or the
485// layer is destroyed; then onDetached (may be nil) receives a
486// *LayerDetachedError, once. A violation at that point closes display and
487// calls onDetached with an error matching ErrExclusionUnavailable; the surface
488// may still draw until the caller stops, so onDetached must make the caller
489// stop. It runs on the goroutine that reads display, so it must only do
490// thread-safe, non-blocking work such as cancelling a context. It never sees
491// the token. The attachment object is never destroyed after a success: that
492// would not release the exclusion, and closing the connection ends it.
493func AuthorizeLayer(ctx context.Context, display *wlturbo.Display, surface *core.Surface, token string, onDetached func(error)) error {
494	return authorizeLayer(ctx, display, surface, token, onDetached, DefaultAttachTimeout)
495}
496
497func authorizeLayer(ctx context.Context, display *wlturbo.Display, surface *core.Surface, token string, onDetached func(error), timeout time.Duration) error {
498	if display == nil || surface == nil || surface.Context() != display.Context() {
499		return errors.New("wayland: authorize layer: surface does not belong to display")
500	}
501	if !validToken(token) {
502		return errors.New("wayland: authorize layer: invalid token")
503	}
504	if err := ctx.Err(); err != nil {
505		return err
506	}
507	m := newRequestOnly(display.Context())
508	if _, err := display.Registry().BindNegotiated(exclusionManagerIface, extensionVersion, m); err != nil {
509		if errors.Is(err, wlturbo.ErrGlobalNotFound) {
510			return ErrExclusionUnsupported
511		}
512		return fmt.Errorf("wayland: bind %s: %w", exclusionManagerIface, err)
513	}
514	wctx, cancel := context.WithTimeout(ctx, timeout)
515	defer cancel()
516	stop := context.AfterFunc(wctx, func() { _ = display.Close() })
517	defer stop()
518
519	l := &layerProxy{display: display, onDetached: onDetached}
520	l.SetContext(display.Context())
521	err := display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqAttachSurface, Name: "neferwl_capture_exclusion_manager_v1.attach_surface", Child: l}, l, token, surface)
522	for err == nil && !l.settled() {
523		err = display.Dispatch()
524	}
525	// Detach the watcher before anything else is decided or sent. When stop
526	// reports false the watcher has fired or is firing: cancellation and the
527	// deadline win over any outcome, and the display is closed here too so it
528	// is closed on return whatever the watcher goroutine is doing. When it
529	// reports true the watcher can never fire, and the caller owns the
530	// connection; a context that is already done is still caught below.
531	if !stop() || wctx.Err() != nil {
532		_ = display.Close()
533		if ctx.Err() != nil {
534			return ctx.Err()
535		}
536		return fmt.Errorf("wayland: attach surface: %w", wctx.Err())
537	}
538	if err != nil {
539		return fmt.Errorf("wayland: attach surface: %w", err)
540	}
541	state, reason, violation := l.hand()
542	if violation {
543		_ = display.Close()
544		return errLayerProtocol
545	}
546	// The manager binding is not needed any more.
547	_ = display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqManagerDestroy, Name: "neferwl_capture_exclusion_manager_v1.destroy", Destructor: true})
548	if state == layerFailed {
549		_ = display.Context().Request(wlturbo.Request{Proxy: l, Opcode: reqLayerDestroy, Name: "neferwl_capture_layer_v1.destroy", Destructor: true})
550		return &AttachFailedError{Reason: AttachFailure(reason)}
551	}
552	return nil
553}
554
555// validToken accepts exactly the compositor's token format.
556func validToken(token string) bool {
557	if len(token) != tokenLen {
558		return false
559	}
560	for i := range len(token) {
561		if c := token[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') {
562			return false
563		}
564	}
565	return true
566}

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