v0.2.0 a558f49

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	"github.com/bnema/wlturbo"
22	"github.com/bnema/wlturbo/protocol/core"
23	"github.com/bnema/wlturbo/protocol/imagecapturesource"
24	"github.com/bnema/wlturbo/protocol/imagecopycapture"
25	"github.com/bnema/wlturbo/protocol/xdgoutput"
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	ext  extensions
96	ws   workspaceState
97}
98
99var _ ports.Source = (*Source)(nil)
100
101// New connects to socket (an absolute path, or a name relative to
102// XDG_RUNTIME_DIR; empty selects WAYLAND_DISPLAY) and discovers outputs.
103//
104// ctx bounds the constructor only: it interrupts discovery, and is detached
105// once New returns, so cancelling it afterwards does not close the Source.
106// Later calls are bound by their own contexts. The logger is derived from ctx.
107func New(ctx context.Context, socket string) (*Source, error) {
108	path, err := socketPath(socket)
109	if err != nil {
110		return nil, err
111	}
112	conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path)
113	if err != nil {
114		return nil, fmt.Errorf("wayland: connect: %w", err)
115	}
116	display, err := wlturbo.ConnectFromConn(conn)
117	if err != nil {
118		return nil, fmt.Errorf("wayland: %w", err)
119	}
120	s := &Source{
121		log:      logging.For(ctx, "wayland"),
122		display:  display,
123		wl:       display.Context(),
124		outputs:  make(map[uint32]*output),
125		byProxy:  make(map[uint32]*output),
126		conn:     conn,
127		pollWait: defaultPollWait,
128	}
129	stop := context.AfterFunc(ctx, s.terminate)
130	err = s.discover()
131	cancelled := !stop()
132	if cancelled || err != nil {
133		s.terminate()
134		if cancelled || ctx.Err() != nil {
135			err = ctx.Err()
136		}
137		s.log.Error().Err(err).Msg("wayland source failed to start")
138		return nil, err
139	}
140	s.log.Info().Int("outputs", len(s.outputs)).Interface("capabilities", s.Capabilities()).
141		Bool("region_source", s.ext.source != nil).
142		Bool("hidden_workspaces", s.ws.mgr != nil && s.ext.source != nil).
143		Msg("wayland source started")
144	return s, nil
145}
146
147func socketPath(socket string) (string, error) {
148	if socket == "" {
149		socket = os.Getenv("WAYLAND_DISPLAY")
150		if socket == "" {
151			socket = "wayland-0"
152		}
153	}
154	if filepath.IsAbs(socket) {
155		return socket, nil
156	}
157	dir := os.Getenv("XDG_RUNTIME_DIR")
158	if dir == "" {
159		return "", errors.New("wayland: XDG_RUNTIME_DIR is not set")
160	}
161	return filepath.Join(dir, socket), nil
162}
163
164func (s *Source) discover() (err error) {
165	reg := s.display.Registry()
166	reg.AddHandler(core.OutputInterface, s.onOutputGlobal)
167	reg.AddGlobalRemoveHandler(globalRemoved{s})
168	if err = s.roundtrip(); err != nil {
169		return err
170	}
171	s.shm = core.NewShm(s.wl)
172	if _, err = reg.BindNegotiated(core.ShmInterface, shmVersion, s.shm); err != nil {
173		return fmt.Errorf("wayland: bind wl_shm: %w", err)
174	}
175	s.copyManager = imagecopycapture.NewExtImageCopyCaptureManager(s.wl)
176	if err = s.bindRequired(imagecopycapture.ExtImageCopyCaptureManagerInterface, s.copyManager); err != nil {
177		return err
178	}
179	s.outSource = imagecapturesource.NewExtOutputImageCaptureSourceManager(s.wl)
180	if err = s.bindRequired(imagecapturesource.ExtOutputImageCaptureSourceManagerInterface, s.outSource); err != nil {
181		return err
182	}
183	if err = s.bindExtras(); err != nil {
184		return err
185	}
186	return s.settle()
187}
188
189// bindRequired binds a global that capture cannot work without.
190func (s *Source) bindRequired(iface string, p wlturbo.Proxy) error {
191	if _, err := s.display.Registry().BindNegotiated(iface, copyVersion, p); err != nil {
192		if errors.Is(err, wlturbo.ErrGlobalNotFound) {
193			return fmt.Errorf("wayland: the compositor does not support ext-image-copy-capture-v1 (%s missing)", iface)
194		}
195		return fmt.Errorf("wayland: bind %s: %w", iface, err)
196	}
197	return nil
198}
199
200// bindOptional binds a global when the compositor offers it, and reports
201// whether it did.
202func (s *Source) bindOptional(iface string, version uint32, p wlturbo.Proxy) (bool, error) {
203	_, err := s.display.Registry().BindNegotiated(iface, version, p)
204	switch {
205	case err == nil:
206		return true, nil
207	case errors.Is(err, wlturbo.ErrGlobalNotFound):
208		return false, nil
209	}
210	return false, fmt.Errorf("wayland: bind %s: %w", iface, err)
211}
212
213func (s *Source) onOutputGlobal(reg *wlturbo.Registry, name, version uint32) {
214	if version == 0 || s.outputs[name] != nil {
215		return // unusable, or a duplicate announcement of a bound global
216	}
217	o := &output{id: name, scale: 1, version: min(version, outputVersion), proxy: core.NewOutput(s.wl)}
218	o.proxy.OnMode(func(flags uint32, w, h, _ int32) {
219		// The mode is informational: implausible values are ignored.
220		if flags&1 != 0 && w >= 0 && h >= 0 && w <= ports.MaxDimension && h <= ports.MaxDimension {
221			o.width, o.height = w, h
222		}
223	})
224	o.proxy.OnScale(func(f int32) { o.scale = min(max(f, 1), maxOutputScale) })
225	o.proxy.OnName(func(n string) { o.name = n })
226	if err := reg.Bind(name, core.OutputInterface, o.version, o.proxy); err != nil {
227		s.log.Warn().Err(err).Uint32("id", name).Msg("bind wl_output failed")
228		return
229	}
230	s.outputs[name] = o
231	s.byProxy[o.proxy.ID()] = o
232	s.bound++
233	if s.xdgOutputs != nil {
234		s.watchLogicalSize(o)
235	}
236}
237
238type globalRemoved struct{ s *Source }
239
240func (g globalRemoved) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemoveEvent) {
241	o := g.s.outputs[e.Name]
242	if o == nil {
243		return
244	}
245	delete(g.s.outputs, e.Name)
246	delete(g.s.byProxy, o.proxy.ID())
247	o.removed = true
248	g.s.releaseXdg(o)
249	g.s.ws.forgetOutput(o.proxy.ID())
250	if o.version >= 3 {
251		_ = o.proxy.Release()
252	} // older wl_output has no destructor: the proxy must stay to absorb events
253}
254
255// terminate closes the connection, which unblocks any Dispatch. It is safe
256// from any goroutine and idempotent.
257func (s *Source) terminate() {
258	s.dead.Store(true)
259	_ = s.display.Close()
260}
261
262func (s *Source) dispatch() error {
263	err := s.display.Dispatch()
264	if err != nil {
265		s.terminate()
266		return fmt.Errorf("wayland: dispatch: %w", err)
267	}
268	return nil
269}
270
271// dispatchUntil is dispatch with a read deadline; a zero deadline waits
272// forever. It reports a missed deadline, which is not an error.
273func (s *Source) dispatchUntil(deadline time.Time) (timedOut bool, err error) {
274	if deadline.IsZero() {
275		return false, s.dispatch()
276	}
277	_ = s.conn.SetReadDeadline(deadline)
278	err = s.display.Dispatch()
279	_ = s.conn.SetReadDeadline(time.Time{})
280	if errors.Is(err, os.ErrDeadlineExceeded) {
281		return true, nil
282	}
283	if err != nil {
284		s.terminate()
285		return false, fmt.Errorf("wayland: dispatch: %w", err)
286	}
287	return false, nil
288}
289
290// settle round-trips until no output was bound meanwhile, so that the events
291// of an output announced during the last round trip have been received too.
292func (s *Source) settle() error {
293	for range maxSettleRounds {
294		before := s.bound
295		if err := s.roundtrip(); err != nil || s.bound == before {
296			return err
297		}
298	}
299	return nil
300}
301
302func (s *Source) roundtrip() error {
303	err := s.display.Roundtrip()
304	if err != nil {
305		s.terminate()
306		return fmt.Errorf("wayland: roundtrip: %w", err)
307	}
308	return nil
309}
310
311// begin starts one serialized operation bound by ctx. The caller must hold
312// opMu and must call finish afterwards. Consecutive calls with the same Done
313// channel share one context.AfterFunc registration; contexts that can never be
314// cancelled register nothing.
315func (s *Source) begin(ctx context.Context) error {
316	if s.dead.Load() {
317		return ports.ErrClosed
318	}
319	if done := ctx.Done(); done != nil {
320		if done != s.watchDone {
321			s.detachWatcher()
322			gen := s.watchGen.Add(1)
323			s.watchDone = done
324			s.watchStop = context.AfterFunc(ctx, func() { s.watchFired(gen) })
325		}
326		// Activate before checking the context: a callback that fired while
327		// idle did nothing, and this check then observes the cancellation.
328		s.watchActive.Store(true)
329	}
330	if err := ctx.Err(); err != nil {
331		s.watchActive.Store(false)
332		s.terminate()
333		s.detachWatcher()
334		s.releaseBuffers()
335		return err
336	}
337	return nil
338}
339
340// watchFired runs on the context's goroutine. It ends the connection only
341// when it belongs to the running call's registration.
342func (s *Source) watchFired(gen uint64) {
343	if s.watchActive.Load() && s.watchGen.Load() == gen {
344		s.terminate()
345	}
346}
347
348// detachWatcher drops the cached registration. The caller holds opMu.
349func (s *Source) detachWatcher() {
350	if s.watchStop != nil {
351		s.watchStop()
352	}
353	s.watchGen.Add(1) // a callback already running is now stale
354	s.watchStop, s.watchDone = nil, nil
355}
356
357// finish ends the call started by begin and classifies err. A cancellation
358// observed at any point makes the Source terminal. When it is terminal, the
359// mapped storage is released before returning.
360//
361// Ordering invariant: a context sets Err before it closes Done and runs
362// AfterFunc callbacks. So a callback that ran while the call was active has
363// already made Err visible, and a callback that ran while idle did nothing but
364// is caught by the Err check. Clearing active before that check therefore
365// cannot report success for a call whose context was cancelled in between.
366func (s *Source) finish(ctx context.Context, err error) error {
367	s.watchActive.Store(false)
368	if cerr := ctx.Err(); cerr != nil {
369		s.terminate()
370	}
371	if !s.dead.Load() {
372		return err
373	}
374	s.detachWatcher()
375	s.releaseBuffers()
376	if cerr := ctx.Err(); cerr != nil {
377		return cerr
378	}
379	if err == nil || errors.Is(err, net.ErrClosed) {
380		return ports.ErrClosed
381	}
382	s.log.Error().Err(err).Msg("wayland source terminated")
383	return err
384}
385
386// Outputs lists the outputs currently announced by the compositor, by ID.
387func (s *Source) Outputs(ctx context.Context) ([]ports.Output, error) {
388	s.opMu.Lock()
389	defer s.opMu.Unlock()
390	if err := s.begin(ctx); err != nil {
391		return nil, err
392	}
393	err := s.settle()
394	var list []ports.Output
395	if err == nil {
396		list = make([]ports.Output, 0, len(s.outputs))
397		for _, o := range s.outputs {
398			list = append(list, ports.Output{ID: o.id, Name: outputName(o), Width: int(o.width), Height: int(o.height), Scale: int(o.scale)})
399		}
400		slices.SortFunc(list, func(a, b ports.Output) int { return cmp.Compare(a.ID, b.ID) })
401	}
402	if err = s.finish(ctx, err); err != nil {
403		return nil, err
404	}
405	return list, nil
406}
407
408// Capture copies one frame of the target into storage reused across calls.
409// The frame is valid until the next Capture or Close.
410func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, error) {
411	if err := validRegion(t.Region); err != nil {
412		return ports.Frame{}, err
413	}
414	s.opMu.Lock()
415	defer s.opMu.Unlock()
416	if err := s.begin(ctx); err != nil {
417		return ports.Frame{}, err
418	}
419	frame, err := s.captureTarget(t)
420	if err = s.finish(ctx, err); err != nil {
421		return ports.Frame{}, err
422	}
423	return frame, nil
424}
425
426// Capabilities reports what the compositor offers beyond output capture.
427func (s *Source) Capabilities() ports.Capabilities {
428	return ports.Capabilities{
429		Workspaces: s.ws.mgr != nil,
430		Exclusion:  s.ext.exclusion != nil,
431	}
432}
433
434// Close terminates the connection, waits for a blocked operation to observe
435// it, and releases the mapped storage. It is idempotent.
436func (s *Source) Close() error {
437	s.terminate()
438	s.opMu.Lock()
439	defer s.opMu.Unlock()
440	s.detachWatcher()
441	return s.releaseBuffers()
442}

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