main 3d41bc5

source.go RawHistoryFolder

1// Package wayland captures frames over ext-image-copy-capture-v1 with the
2// wlturbo transport and wl_shm buffers. It lists workspaces through
3// ext-workspace-v1 and uses NeferWL's optional extension, when the compositor
4// has it, for workspace and region sources and for excluding the recording
5// HUD from frames.
6package wayland
7
8import (
9	"cmp"
10	"context"
11	"errors"
12	"fmt"
13	"net"
14	"os"
15	"path/filepath"
16	"slices"
17	"sync"
18	"sync/atomic"
19	"time"
20
21	imagecapturesource "github.com/bnema/go-wayland-bindings/client/extimagecapturesource"
22	imagecopycapture "github.com/bnema/go-wayland-bindings/client/extimagecopycapture"
23	core "github.com/bnema/go-wayland-bindings/client/wayland"
24	"github.com/bnema/go-wayland-bindings/client/xdgoutput"
25	"github.com/bnema/wlturbo"
26	"github.com/bnema/zerowrap"
27
28	"github.com/bnema/nefercap/internal/logging"
29	"github.com/bnema/nefercap/internal/ports"
30)
31
32const (
33	outputVersion    = 4 // wl_output.name arrived in version 4
34	shmVersion       = 1
35	copyVersion      = 1
36	xdgOutputVersion = 3
37	maxSettleRounds  = 4
38	maxOutputScale   = 16 // informational; larger announcements are clamped
39	// defaultPollWait is how long Capture waits for a frame the compositor may
40	// hold back until the screen changes; short against a frame interval.
41	defaultPollWait = 2 * time.Millisecond
42)
43
44// output is the client-side state of one bound wl_output global.
45type output struct {
46	proxy         *core.Output
47	id            uint32 // wl_registry global name, stable for the connection
48	version       uint32
49	name          string
50	width, height int32
51	scale         int32
52	lw, lh        int32 // logical size from xdg-output, 0 when unknown
53	xdg           *xdgoutput.ZxdgOutput
54	removed       bool
55}
56
57// Source is a ports.Source backed by one Wayland connection.
58//
59// All protocol events are dispatched synchronously by the goroutine that runs
60// Outputs or Capture, so event handlers need no locking. Calls are serialized
61// by opMu. Cancelling the context of a running call, a protocol failure or
62// Close closes the connection: the Source is then terminal and every later
63// call returns ports.ErrClosed. The call that observed a cancellation returns
64// its context's error. A context cancelled between calls affects only a later
65// call that is given that same context.
66type Source struct {
67	log     zerowrap.Logger
68	display *wlturbo.Display
69	wl      *wlturbo.Context
70
71	opMu sync.Mutex // serializes operations and guards everything below
72	dead atomic.Bool
73
74	// Cancellation watcher, reused while consecutive calls share a Done
75	// channel. watchDone and watchStop are guarded by opMu; the callback only
76	// reads the atomics, and acts only while a call is active and its
77	// registration is still the current generation.
78	watchDone   <-chan struct{}
79	watchStop   func() bool
80	watchGen    atomic.Uint64
81	watchActive atomic.Bool
82
83	shm         *core.Shm
84	copyManager *imagecopycapture.ExtImageCopyCaptureManager
85	outSource   *imagecapturesource.ExtOutputImageCaptureSourceManager
86	xdgOutputs  *xdgoutput.ZxdgOutputManager // nil when absent
87	outputs     map[uint32]*output
88	byProxy     map[uint32]*output // wl_output proxy ID to output
89	bound       int                // wl_output globals bound so far
90	bufs        [2]shmBuffer
91	conn        net.Conn
92	pollWait    time.Duration // how long Capture waits for a pending frame
93
94	sess   *session               // the one live capture session, nil before the first capture
95	frozen map[uint32]ports.Frame // by output ID, set by Freeze: Capture serves these
96	ext    extensions
97	ws     workspaceState
98}
99
100var _ ports.Source = (*Source)(nil)
101
102// New connects to socket (an absolute path, or a name relative to
103// XDG_RUNTIME_DIR; empty selects WAYLAND_DISPLAY) and discovers outputs.
104//
105// ctx bounds the constructor only: it interrupts discovery, and is detached
106// once New returns, so cancelling it afterwards does not close the Source.
107// Later calls are bound by their own contexts. The logger is derived from ctx.
108func New(ctx context.Context, socket string) (*Source, error) {
109	path, err := socketPath(socket)
110	if err != nil {
111		return nil, err
112	}
113	conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path)
114	if err != nil {
115		return nil, fmt.Errorf("wayland: connect: %w", err)
116	}
117	display, err := wlturbo.ConnectFromConn(conn)
118	if err != nil {
119		return nil, fmt.Errorf("wayland: %w", err)
120	}
121	s := &Source{
122		log:      logging.For(ctx, "wayland"),
123		display:  display,
124		wl:       display.Context(),
125		outputs:  make(map[uint32]*output),
126		byProxy:  make(map[uint32]*output),
127		conn:     conn,
128		pollWait: defaultPollWait,
129	}
130	stop := context.AfterFunc(ctx, s.terminate)
131	err = s.discover()
132	cancelled := !stop()
133	if cancelled || err != nil {
134		s.terminate()
135		if cancelled || ctx.Err() != nil {
136			err = ctx.Err()
137		}
138		s.log.Error().Err(err).Msg("wayland source failed to start")
139		return nil, err
140	}
141	s.log.Info().Int("outputs", len(s.outputs)).Interface("capabilities", s.Capabilities()).
142		Bool("region_source", s.ext.source != nil).
143		Bool("hidden_workspaces", s.ws.mgr != nil && s.ext.source != nil).
144		Msg("wayland source started")
145	return s, nil
146}
147
148func socketPath(socket string) (string, error) {
149	if socket == "" {
150		socket = os.Getenv("WAYLAND_DISPLAY")
151		if socket == "" {
152			socket = "wayland-0"
153		}
154	}
155	if filepath.IsAbs(socket) {
156		return socket, nil
157	}
158	dir := os.Getenv("XDG_RUNTIME_DIR")
159	if dir == "" {
160		return "", errors.New("wayland: XDG_RUNTIME_DIR is not set")
161	}
162	return filepath.Join(dir, socket), nil
163}
164
165func (s *Source) discover() (err error) {
166	reg := s.display.Registry()
167	reg.AddHandler(core.OutputInterface, s.onOutputGlobal)
168	reg.AddGlobalRemoveHandler(globalRemoved{s})
169	if err = s.roundtrip(); err != nil {
170		return err
171	}
172	s.shm = core.NewShm(s.wl)
173	if _, err = reg.BindNegotiated(core.ShmInterface, shmVersion, s.shm); err != nil {
174		return fmt.Errorf("wayland: bind wl_shm: %w", err)
175	}
176	s.copyManager = imagecopycapture.NewExtImageCopyCaptureManager(s.wl)
177	if err = s.bindRequired(imagecopycapture.ExtImageCopyCaptureManagerInterface, s.copyManager); err != nil {
178		return err
179	}
180	s.outSource = imagecapturesource.NewExtOutputImageCaptureSourceManager(s.wl)
181	if err = s.bindRequired(imagecapturesource.ExtOutputImageCaptureSourceManagerInterface, s.outSource); err != nil {
182		return err
183	}
184	if err = s.bindExtras(); err != nil {
185		return err
186	}
187	return s.settle()
188}
189
190// bindRequired binds a global that capture cannot work without.
191func (s *Source) bindRequired(iface string, p wlturbo.Proxy) error {
192	if _, err := s.display.Registry().BindNegotiated(iface, copyVersion, p); err != nil {
193		if errors.Is(err, wlturbo.ErrGlobalNotFound) {
194			return fmt.Errorf("wayland: the compositor does not support ext-image-copy-capture-v1 (%s missing)", iface)
195		}
196		return fmt.Errorf("wayland: bind %s: %w", iface, err)
197	}
198	return nil
199}
200
201// bindOptional binds a global when the compositor offers it, and reports
202// whether it did.
203func (s *Source) bindOptional(iface string, version uint32, p wlturbo.Proxy) (bool, error) {
204	_, err := s.display.Registry().BindNegotiated(iface, version, p)
205	switch {
206	case err == nil:
207		return true, nil
208	case errors.Is(err, wlturbo.ErrGlobalNotFound):
209		return false, nil
210	}
211	return false, fmt.Errorf("wayland: bind %s: %w", iface, err)
212}
213
214func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) {
215	if version == 0 || s.outputs[name] != nil {
216		return // unusable, or a duplicate announcement of a bound global
217	}
218	o := &output{id: name, scale: 1, version: min(version, outputVersion), proxy: core.NewOutput(s.wl)}
219	o.proxy.OnMode(func(flags uint32, w, h, _ int32) {
220		// The mode is informational: implausible values are ignored.
221		if flags&1 != 0 && w >= 0 && h >= 0 && w <= ports.MaxDimension && h <= ports.MaxDimension {
222			o.width, o.height = w, h
223		}
224	})
225	o.proxy.OnScale(func(f int32) { o.scale = min(max(f, 1), maxOutputScale) })
226	o.proxy.OnName(func(n string) { o.name = n })
227	if err := reg.Bind(name, core.OutputInterface, o.version, o.proxy); err != nil {
228		s.log.Warn().Err(err).Uint32("id", name).Msg("bind wl_output failed")
229		return
230	}
231	s.outputs[name] = o
232	s.byProxy[o.proxy.ID()] = o
233	s.bound++
234	if s.xdgOutputs != nil {
235		s.watchLogicalSize(o)
236	}
237}
238
239type globalRemoved struct{ s *Source }
240
241func (g globalRemoved) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemoveEvent) {
242	o := g.s.outputs[e.Name]
243	if o == nil {
244		return
245	}
246	delete(g.s.outputs, e.Name)
247	delete(g.s.byProxy, o.proxy.ID())
248	o.removed = true
249	g.s.releaseXdg(o)
250	g.s.ws.forgetOutput(o.proxy.ID())
251	if o.version >= 3 {
252		_ = o.proxy.Release()
253	} // older wl_output has no destructor: the proxy must stay to absorb events
254}
255
256// terminate closes the connection, which unblocks any Dispatch. It is safe
257// from any goroutine and idempotent.
258func (s *Source) terminate() {
259	s.dead.Store(true)
260	_ = s.display.Close()
261}
262
263func (s *Source) dispatch() error {
264	err := s.display.Dispatch()
265	if err != nil {
266		s.terminate()
267		return fmt.Errorf("wayland: dispatch: %w", err)
268	}
269	return nil
270}
271
272// dispatchUntil is dispatch with a read deadline; a zero deadline waits
273// forever. It reports a missed deadline, which is not an error.
274func (s *Source) dispatchUntil(deadline time.Time) (timedOut bool, err error) {
275	if deadline.IsZero() {
276		return false, s.dispatch()
277	}
278	_ = s.conn.SetReadDeadline(deadline)
279	err = s.display.Dispatch()
280	_ = s.conn.SetReadDeadline(time.Time{})
281	if errors.Is(err, os.ErrDeadlineExceeded) {
282		return true, nil
283	}
284	if err != nil {
285		s.terminate()
286		return false, fmt.Errorf("wayland: dispatch: %w", err)
287	}
288	return false, nil
289}
290
291// settle round-trips until no output was bound meanwhile, so that the events
292// of an output announced during the last round trip have been received too.
293func (s *Source) settle() error {
294	for range maxSettleRounds {
295		before := s.bound
296		if err := s.roundtrip(); err != nil || s.bound == before {
297			return err
298		}
299	}
300	return nil
301}
302
303func (s *Source) roundtrip() error {
304	err := s.display.Roundtrip()
305	if err != nil {
306		s.terminate()
307		return fmt.Errorf("wayland: roundtrip: %w", err)
308	}
309	return nil
310}
311
312// begin starts one serialized operation bound by ctx. The caller must hold
313// opMu and must call finish afterwards. Consecutive calls with the same Done
314// channel share one context.AfterFunc registration; contexts that can never be
315// cancelled register nothing.
316func (s *Source) begin(ctx context.Context) error {
317	if s.dead.Load() {
318		return ports.ErrClosed
319	}
320	if done := ctx.Done(); done != nil {
321		if done != s.watchDone {
322			s.detachWatcher()
323			gen := s.watchGen.Add(1)
324			s.watchDone = done
325			s.watchStop = context.AfterFunc(ctx, func() { s.watchFired(gen) })
326		}
327		// Activate before checking the context: a callback that fired while
328		// idle did nothing, and this check then observes the cancellation.
329		s.watchActive.Store(true)
330	}
331	if err := ctx.Err(); err != nil {
332		s.watchActive.Store(false)
333		s.terminate()
334		s.detachWatcher()
335		s.releaseBuffers()
336		return err
337	}
338	return nil
339}
340
341// watchFired runs on the context's goroutine. It ends the connection only
342// when it belongs to the running call's registration.
343func (s *Source) watchFired(gen uint64) {
344	if s.watchActive.Load() && s.watchGen.Load() == gen {
345		s.terminate()
346	}
347}
348
349// detachWatcher drops the cached registration. The caller holds opMu.
350func (s *Source) detachWatcher() {
351	if s.watchStop != nil {
352		s.watchStop()
353	}
354	s.watchGen.Add(1) // a callback already running is now stale
355	s.watchStop, s.watchDone = nil, nil
356}
357
358// finish ends the call started by begin and classifies err. A cancellation
359// observed at any point makes the Source terminal. When it is terminal, the
360// mapped storage is released before returning.
361//
362// Ordering invariant: a context sets Err before it closes Done and runs
363// AfterFunc callbacks. So a callback that ran while the call was active has
364// already made Err visible, and a callback that ran while idle did nothing but
365// is caught by the Err check. Clearing active before that check therefore
366// cannot report success for a call whose context was cancelled in between.
367func (s *Source) finish(ctx context.Context, err error) error {
368	s.watchActive.Store(false)
369	if cerr := ctx.Err(); cerr != nil {
370		s.terminate()
371	}
372	if !s.dead.Load() {
373		return err
374	}
375	s.detachWatcher()
376	s.releaseBuffers()
377	if cerr := ctx.Err(); cerr != nil {
378		return cerr
379	}
380	if err == nil || errors.Is(err, net.ErrClosed) {
381		return ports.ErrClosed
382	}
383	s.log.Error().Err(err).Msg("wayland source terminated")
384	return err
385}
386
387// Outputs lists the outputs currently announced by the compositor, by ID.
388func (s *Source) Outputs(ctx context.Context) ([]ports.Output, error) {
389	s.opMu.Lock()
390	defer s.opMu.Unlock()
391	if err := s.begin(ctx); err != nil {
392		return nil, err
393	}
394	err := s.settle()
395	var list []ports.Output
396	if err == nil {
397		list = make([]ports.Output, 0, len(s.outputs))
398		for _, o := range s.outputs {
399			list = append(list, ports.Output{ID: o.id, Name: outputName(o), Width: int(o.width), Height: int(o.height), Scale: int(o.scale)})
400		}
401		slices.SortFunc(list, func(a, b ports.Output) int { return cmp.Compare(a.ID, b.ID) })
402	}
403	if err = s.finish(ctx, err); err != nil {
404		return nil, err
405	}
406	return list, nil
407}
408
409// Capture copies one frame of the target into storage reused across calls.
410// The frame is valid until the next Capture or Close.
411func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, error) {
412	if err := validRegion(t.Region); err != nil {
413		return ports.Frame{}, err
414	}
415	s.opMu.Lock()
416	defer s.opMu.Unlock()
417	if err := s.begin(ctx); err != nil {
418		return ports.Frame{}, err
419	}
420	frame, frozen, err := s.frozenTarget(t)
421	if !frozen {
422		frame, err = s.captureTarget(t)
423	}
424	if err = s.finish(ctx, err); err != nil {
425		return ports.Frame{}, err
426	}
427	return frame, nil
428}
429
430// Capabilities reports what the compositor offers beyond output capture.
431func (s *Source) Capabilities() ports.Capabilities {
432	return ports.Capabilities{
433		Workspaces: s.ws.mgr != nil,
434		Exclusion:  s.ext.exclusion != nil,
435	}
436}
437
438// Close terminates the connection, waits for a blocked operation to observe
439// it, and releases the mapped storage. It is idempotent.
440func (s *Source) Close() error {
441	s.terminate()
442	s.opMu.Lock()
443	defer s.opMu.Unlock()
444	s.detachWatcher()
445	s.frozen = nil
446	return s.releaseBuffers()
447}

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