1package wayland
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "reflect"
8 "sync"
9 "time"
10
11 imagecapturesource "github.com/bnema/go-wayland-bindings/client/extimagecapturesource"
12 "github.com/bnema/go-wayland-bindings/client/xdgoutput"
13 "github.com/bnema/wlturbo"
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 the goroutine that reads the GUI connection; answered is
380// closed on the first answer or violation, so authorizeLayer can wait for it.
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 answered chan struct{}
389
390 mu sync.Mutex
391 state layerState
392 failReason uint32
393 violation bool
394 returned bool // authorizeLayer handed the surface to its caller
395 notified bool
396}
397
398type layerState int
399
400const (
401 layerPending layerState = iota
402 layerAttached
403 layerFailed
404 layerDetached
405)
406
407func (*layerProxy) EventSignature(op uint16) (string, bool) {
408 switch op {
409 case 0:
410 return "", true
411 case 1, 2:
412 return "uint,", true
413 }
414 return "", false
415}
416
417func (l *layerProxy) Dispatch(e *wlturbo.Event) {
418 var arg uint32
419 if e.Opcode != 0 {
420 arg = e.Uint32()
421 }
422 var notify error
423 closeDisplay := false
424 l.mu.Lock()
425 returned := l.returned
426 switch {
427 case l.violation:
428 // Terminal: the display is closed already.
429 case e.Opcode == 0 && l.state == layerPending:
430 l.state = layerAttached
431 case e.Opcode == 1 && l.state == layerPending:
432 l.state, l.failReason = layerFailed, arg
433 case e.Opcode == 2 && l.state == layerAttached:
434 l.state = layerDetached
435 if returned {
436 l.notified, notify = true, &LayerDetachedError{Reason: DetachReason(arg)}
437 }
438 default:
439 l.violation, closeDisplay = true, true
440 if returned && !l.notified {
441 l.notified, notify = true, errLayerProtocol
442 }
443 }
444 settled := l.state != layerPending || l.violation
445 l.mu.Unlock()
446 if settled {
447 l.answer()
448 }
449 if closeDisplay && returned {
450 _ = l.display.Close() // the surface could be visible in captures
451 }
452 if notify != nil && l.onDetached != nil {
453 l.onDetached(notify)
454 }
455}
456
457// answer closes answered once; wlturbo serializes Dispatch calls.
458func (l *layerProxy) answer() {
459 select {
460 case <-l.answered:
461 default:
462 close(l.answered)
463 }
464}
465
466// hand marks the layer as owned by the caller of authorizeLayer, and reports
467// its outcome so far.
468func (l *layerProxy) hand() (state layerState, reason uint32, violation bool) {
469 l.mu.Lock()
470 defer l.mu.Unlock()
471 l.returned = true
472 return l.state, l.failReason, l.violation
473}
474
475// AuthorizeLayer tells the compositor, from a connection other than the
476// capture connection, that surface is a layer surface of the capturing peer
477// and must be excluded from the frames of the session that owns token (from
478// Source.BeginExclusion). Call it after the layer-shell role is assigned and
479// before the surface is mapped; when it returns nil the compositor has
480// confirmed the exclusion for this very attachment, so the surface is safe to
481// show.
482//
483// Other goroutines must keep reading display's events meanwhile (with
484// neferclient: its reader, and the owner calling Conn.Dispatch so the reader
485// is never stalled by a full queue). AuthorizeLayer only sends the request
486// and waits for the answer, so it never competes with them. A refusal returns an
487// *AttachFailedError. A protocol error is returned as the
488// display's error. A cancelled ctx, or no answer within DefaultAttachTimeout
489// (an error wrapping context.DeadlineExceeded), closes display before
490// returning: the surface must then stay unmapped. So does a violation of the
491// attachment protocol (an answer twice, a detach before the answer, ...).
492//
493// After it returns nil the attachment stays until the exclusion ends or the
494// layer is destroyed; then onDetached (may be nil) receives a
495// *LayerDetachedError, once. A violation at that point closes display and
496// calls onDetached with an error matching ErrExclusionUnavailable; the surface
497// may still draw until the caller stops, so onDetached must make the caller
498// stop. It runs on the goroutine that reads display, so it must only do
499// thread-safe, non-blocking work such as cancelling a context. It never sees
500// the token. The attachment object is never destroyed after a success: that
501// would not release the exclusion, and closing the connection ends it.
502//
503// surface is the layer's wl_surface proxy, whichever bindings created it; it
504// must belong to display.
505func AuthorizeLayer(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy, token string, onDetached func(error)) error {
506 return authorizeLayer(ctx, display, surface, token, onDetached, DefaultAttachTimeout)
507}
508
509func authorizeLayer(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy, token string, onDetached func(error), timeout time.Duration) error {
510 if display == nil || surface == nil || nilPointer(surface) || surface.Context() != display.Context() {
511 return errors.New("wayland: authorize layer: surface does not belong to display")
512 }
513 if !validToken(token) {
514 return errors.New("wayland: authorize layer: invalid token")
515 }
516 if err := ctx.Err(); err != nil {
517 return err
518 }
519 m := newRequestOnly(display.Context())
520 if _, err := display.Registry().BindNegotiated(exclusionManagerIface, extensionVersion, m); err != nil {
521 if errors.Is(err, wlturbo.ErrGlobalNotFound) {
522 return ErrExclusionUnsupported
523 }
524 return fmt.Errorf("wayland: bind %s: %w", exclusionManagerIface, err)
525 }
526 wctx, cancel := context.WithTimeout(ctx, timeout)
527 defer cancel()
528 stop := context.AfterFunc(wctx, func() { _ = display.Close() })
529 defer stop()
530
531 l := &layerProxy{display: display, onDetached: onDetached, answered: make(chan struct{})}
532 l.SetContext(display.Context())
533 err := display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqAttachSurface, Name: "neferwl_capture_exclusion_manager_v1.attach_surface", Child: l}, l, token, surface)
534 if err == nil {
535 select {
536 case <-l.answered:
537 case <-wctx.Done(): // the watcher closes display
538 }
539 }
540 // Detach the watcher before anything else is decided or sent. When stop
541 // reports false the watcher has fired or is firing: cancellation and the
542 // deadline win over any outcome, and the display is closed here too so it
543 // is closed on return whatever the watcher goroutine is doing. When it
544 // reports true the watcher can never fire, and the caller owns the
545 // connection; a context that is already done is still caught below.
546 if !stop() || wctx.Err() != nil {
547 _ = display.Close()
548 if ctx.Err() != nil {
549 return ctx.Err()
550 }
551 return fmt.Errorf("wayland: attach surface: %w", wctx.Err())
552 }
553 if err != nil {
554 return fmt.Errorf("wayland: attach surface: %w", err)
555 }
556 state, reason, violation := l.hand()
557 if violation {
558 _ = display.Close()
559 return errLayerProtocol
560 }
561 // The manager binding is not needed any more.
562 _ = display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqManagerDestroy, Name: "neferwl_capture_exclusion_manager_v1.destroy", Destructor: true})
563 if state == layerFailed {
564 _ = display.Context().Request(wlturbo.Request{Proxy: l, Opcode: reqLayerDestroy, Name: "neferwl_capture_layer_v1.destroy", Destructor: true})
565 return &AttachFailedError{Reason: AttachFailure(reason)}
566 }
567 return nil
568}
569
570// nilPointer reports a typed nil proxy, which is a non-nil interface.
571func nilPointer(p wlturbo.Proxy) bool {
572 v := reflect.ValueOf(p)
573 return v.Kind() == reflect.Pointer && v.IsNil()
574}
575
576// validToken accepts exactly the compositor's token format.
577func validToken(token string) bool {
578 if len(token) != tokenLen {
579 return false
580 }
581 for i := range len(token) {
582 if c := token[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') {
583 return false
584 }
585 }
586 return true
587}