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 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}