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}