1package wayland
2
3import (
4 "errors"
5 "image"
6 "reflect"
7 "slices"
8 "time"
9
10 "github.com/bnema/neferwl/internal/ports"
11 "golang.org/x/sys/unix"
12)
13
14// Screencast. The screencast adapter (a D-Bus service that feeds PipeWire
15// streams) drives casts through ports.CastCommand on Channels.CastCommands
16// and hears back through Options.Casts (ports.CastSink). A cast is a capture
17// target registered with core like an ext-image-copy-capture session
18// (ports.CaptureSessionOpen), so the capture indicator stays on while it
19// lives, but its frames are written straight into the PipeWire buffer the
20// adapter lends (ports.CaptureRequest.Mapped) and it has no wayland client:
21// the adapter checked its caller, so casts bypass mayCapture, not protected().
22//
23// Everything here runs on the display goroutine, which owns the casts. The
24// frame cycle (CastFrame, the capture reply, CastFrameDone) and the damage
25// events allocate nothing; opening a cast and publishing the inventory may.
26
27// maxCasts bounds the live casts. Each has at most one frame in flight, and
28// a frame takes one of the maxCaptureInflight admission credits like any
29// capture, so capturedRingSize (MaxCaptureInflight + maxCasts) holds every
30// reply.
31const maxCasts = 8
32
33// castRingSize bounds the cast commands waiting for the display goroutine.
34const castRingSize = 64
35
36var (
37 errCastNoTarget = errors.New("cast: target missing")
38 errCastLimit = errors.New("cast: too many casts")
39 errCastProtected = errors.New("cast: session is protected")
40 errCastStopped = errors.New("cast: stopped")
41 errCastNotReady = errors.New("cast: target not ready")
42 errCastSize = errors.New("cast: frame size differs from the target")
43 errCastBusy = errors.New("cast: frame already in flight")
44 errCastUnknown = errors.New("cast: unknown cast")
45)
46
47// castSession is one cast.
48type castSession struct {
49 captureTarget
50 cast uint64 // the adapter's ID
51 cursor bool
52 // first: CaptureFrameTaken was sent, which turns the indicator on.
53 first bool
54 size image.Point // the size the adapter was last told
55 // dirty: the window committed since the last CastDamage (window casts).
56 dirty bool
57 stopped bool
58 // frame is the frame the adapter waits for (waiting) and, once its
59 // capture was accepted, whether the output owns the buffer (inflight).
60 frame ports.CastCommand
61 waiting bool
62 inflight bool
63 deadline time.Time
64 retryTimer ports.Timer
65 // doneFn, retryFn and timerFn are bound once, so a frame binds no
66 // closure: the capture reply, the retry on the display goroutine and the
67 // timer callback that posts it.
68 doneFn func(ports.CaptureDone)
69 retryFn func()
70 timerFn func()
71 // ending: the cast ended while a capture still wrote into the adapter's
72 // buffer; CastStopped follows its reply.
73 ending, announce bool
74 endReason ports.CaptureReason
75 endErr error
76}
77
78// postRetry is the retry timer's callback, on the timer's goroutine: it
79// posts the retry to the display goroutine.
80func (c *castSession) postRetry() { c.s.display.Post(c.retryFn) }
81
82// push hands an event to the adapter.
83func (c *castSession) push(ev ports.CastEvent) {
84 ev.ID = c.cast
85 c.s.pushCast(ev)
86}
87
88// pushCast sends ev to the sink; Push never blocks.
89func (s *Server) pushCast(ev ports.CastEvent) {
90 if s.casts != nil {
91 s.casts.Push(ev)
92 }
93}
94
95// applyCast runs one command of the adapter on the display goroutine.
96func (s *Server) applyCast(cmd ports.CastCommand) {
97 switch cmd.Kind {
98 case ports.CastOpen:
99 s.openCast(cmd)
100 case ports.CastFrame:
101 c := s.castSessions[cmd.ID]
102 switch {
103 case c == nil:
104 s.refuseCastFrame(cmd, errCastUnknown)
105 case c.waiting:
106 s.refuseCastFrame(cmd, errCastBusy)
107 default:
108 c.frame, c.waiting = cmd, true
109 c.deadline = s.clock.Now().Add(captureRetryFor)
110 c.attempt()
111 }
112 case ports.CastClose:
113 if c := s.castSessions[cmd.ID]; c != nil {
114 c.end(ports.CaptureReasonNone, nil, true, false)
115 }
116 }
117}
118
119// refuseCastFrame answers a frame no cast takes, so the adapter gets its
120// buffer back.
121func (s *Server) refuseCastFrame(cmd ports.CastCommand, err error) {
122 s.pushCast(ports.CastEvent{Kind: ports.CastFrameDone, ID: cmd.ID, Buf: cmd.Buf, Err: err})
123}
124
125// refuseCast ends a cast that never began.
126func (s *Server) refuseCast(id uint64, reason ports.CaptureReason, err error) {
127 s.log.Info().Uint64("cast", id).Str("reason", string(reason)).Err(err).Msg("cast refused")
128 s.pushCast(ports.CastEvent{Kind: ports.CastStopped, ID: id, Reason: reason, Err: err})
129}
130
131// openCast registers a cast with core and tells the adapter its size.
132func (s *Server) openCast(cmd ports.CastCommand) {
133 switch {
134 case s.castSessions[cmd.ID] != nil:
135 s.log.Warn().Uint64("cast", cmd.ID).Msg("cast open: duplicate ID ignored")
136 return
137 case len(s.castSessions) >= maxCasts:
138 s.refuseCast(cmd.ID, ports.CaptureReasonNone, errCastLimit)
139 return
140 case s.protected():
141 s.refuseCast(cmd.ID, ports.CaptureReasonNone, errCastProtected)
142 return
143 }
144 src := &captureSource{kind: srcDead}
145 open := ports.CaptureSessionOpen{}
146 gone := ports.CaptureReasonOutputGone
147 if cmd.Window != 0 {
148 gone = ports.CaptureReasonWindowGone
149 if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil {
150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}
151 open.Window = cmd.Window
152 }
153 } else if o := s.outputByNameExact(cmd.Output); o != nil {
154 src = &captureSource{kind: srcOutput, output: o}
155 open.Output = o.name()
156 }
157 c := &castSession{captureTarget: captureTarget{s: s, src: src}, cast: cmd.ID, cursor: cmd.Cursor}
158 if src.kind == srcDead {
159 s.refuseCast(cmd.ID, gone, errCastNoTarget)
160 return
161 }
162 _, rect, _, ok := c.geometry()
163 if !ok {
164 s.refuseCast(cmd.ID, gone, errCastNoTarget)
165 return
166 }
167 c.doneFn, c.retryFn, c.timerFn = c.onDone, c.retryNow, c.postRetry
168 s.nextSession++
169 c.id = s.nextSession
170 open.ID = c.id
171 c.size = image.Pt(rect.Dx(), rect.Dy())
172 s.castSessions[cmd.ID], s.castByCore[c.id] = c, c
173 s.emit(open)
174 s.log.Info().Uint64("cast", cmd.ID).Uint64("session", c.id).Int("w", c.size.X).Int("h", c.size.Y).Msg("cast open")
175 c.push(ports.CastEvent{Kind: ports.CastSize, Width: c.size.X, Height: c.size.Y})
176}
177
178// state applies what core decided about the cast.
179func (c *castSession) state(st ports.CaptureSessionState) {
180 if c.applyState(st) {
181 c.end(st.Reason, nil, false, true)
182 return
183 }
184 c.refresh()
185}
186
187// refresh follows the target: a new size goes to the adapter, which has to
188// renegotiate its stream; nothing left of the target ends the cast.
189func (c *castSession) refresh() {
190 if c.stopped {
191 return
192 }
193 fate, rect, ok := c.follow()
194 switch {
195 case fate == targetWorkspaceGone:
196 c.end(ports.CaptureReasonWorkspaceGone, nil, true, true)
197 case fate == targetEmpty:
198 c.s.log.Info().Uint64("cast", c.cast).Msg("cast end: target empty")
199 c.end(ports.CaptureReasonOutputGone, nil, true, true)
200 case ok && image.Pt(rect.Dx(), rect.Dy()) != c.size:
201 c.size = image.Pt(rect.Dx(), rect.Dy())
202 c.push(ports.CastEvent{Kind: ports.CastSize, Width: c.size.X, Height: c.size.Y})
203 }
204}
205
206// end stops the cast once. notify tells core (which has to forget it unless
207// it decided the end itself), announce tells the adapter (CastStopped; not
208// for its own CastClose). A capture that still writes into the adapter's
209// buffer finishes first: its CastFrameDone, then CastStopped, so CastStopped
210// means the buffers are the adapter's again.
211func (c *castSession) end(reason ports.CaptureReason, err error, notify, announce bool) {
212 if c.stopped {
213 return
214 }
215 s := c.s
216 c.stopped = true
217 if c.retryTimer != nil {
218 c.retryTimer.Stop()
219 }
220 delete(s.castSessions, c.cast)
221 delete(s.castByCore, c.id)
222 if notify {
223 s.emit(ports.CaptureSessionClose{ID: c.id})
224 }
225 c.announce, c.endReason, c.endErr = announce, reason, err
226 if c.inflight {
227 c.ending = true
228 return
229 }
230 if c.waiting {
231 c.frameDone(errCastStopped, time.Time{})
232 }
233 c.finish()
234}
235
236// finish tells the adapter the cast is over.
237func (c *castSession) finish() {
238 if c.announce {
239 c.push(ports.CastEvent{Kind: ports.CastStopped, Reason: c.endReason, Err: c.endErr})
240 }
241}
242
243// frameDone answers the frame the adapter waits for.
244func (c *castSession) frameDone(err error, at time.Time) {
245 c.waiting = false
246 c.push(ports.CastEvent{Kind: ports.CastFrameDone, Buf: c.frame.Buf, Err: err, Time: at})
247 c.frame = ports.CastCommand{}
248}
249
250// retryNow is the posted retry timer: the frame is tried again.
251func (c *castSession) retryNow() {
252 if !c.stopped && c.waiting && !c.inflight {
253 c.attempt()
254 }
255}
256
257// retry tries the frame again shortly, or fails it past its deadline, as a
258// frame of an ext session does (captureExtFrame.retry).
259func (c *castSession) retry(why error) {
260 s := c.s
261 if !s.clock.Now().Before(c.deadline) {
262 s.log.Info().Uint64("cast", c.cast).Err(why).Msg("cast frame failed after retries")
263 c.frameDone(why, time.Time{})
264 return
265 }
266 if c.retryTimer == nil {
267 c.retryTimer = s.clock.AfterFunc(captureRetryEvery, c.timerFn)
268 return
269 }
270 c.retryTimer.Reset(captureRetryEvery)
271}
272
273// attempt serves the waiting frame from the target as it is now.
274func (c *castSession) attempt() {
275 s := c.s
276 if s.protected() {
277 c.end(ports.CaptureReasonNone, errCastProtected, true, true)
278 return
279 }
280 o, rect, hidden, ok := c.geometry()
281 if !ok || c.haveState && !c.st.Active {
282 c.retry(errCastNotReady)
283 return
284 }
285 if rect.Dx() != c.frame.Width || rect.Dy() != c.frame.Height {
286 // The target moved on: the adapter has to renegotiate before it can
287 // ask again; tell it now if refresh has not yet.
288 c.refresh()
289 if !c.stopped {
290 c.frameDone(errCastSize, time.Time{})
291 }
292 return
293 }
294 if !s.requestCast(c, o, rect, c.targetTag(hidden)) {
295 c.retry(ports.ErrCaptureBusy)
296 }
297}
298
299// requestCast queues the capture of the waiting frame into the adapter's
300// buffer. The reply is the cast's own prebound function, not a closure per
301// frame. False: the in-flight bound or the renderer queue is full.
302func (s *Server) requestCast(c *castSession, o *output, rect image.Rectangle, tag captureTag) bool {
303 f := c.frame
304 if o == nil || s.channels.Captures == nil || s.channels.Captured == nil || len(s.captureInflight) >= maxCaptureInflight {
305 return false
306 }
307 s.nextCapture++
308 id := s.nextCapture
309 s.captureInflight[id] = struct{}{}
310 s.captureReplies[id] = c.doneFn
311 req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: c.cursor, Mapped: f.Data, Width: f.Width, Height: f.Height, Stride: f.Stride, Format: f.Format, Session: tag.session, Workspace: tag.workspace, Window: tag.window, OffScreen: tag.offscreen, Indicate: true}
312 select {
313 case s.channels.Captures <- req:
314 default:
315 delete(s.captureReplies, id)
316 delete(s.captureInflight, id)
317 return false
318 }
319 c.inflight = true
320 if !c.first {
321 // The indicator is on screen while the frame is rendered; the cast
322 // keeps it on for as long as it lives.
323 c.first = true
324 taken := tag.taken
325 taken.Session = tag.session
326 s.emit(taken)
327 }
328 return true
329}
330
331// onDone settles the capture of the waiting frame.
332func (c *castSession) onDone(done ports.CaptureDone) {
333 c.inflight = false
334 if errors.Is(done.Err, ports.ErrCaptureTransient) && !c.stopped {
335 c.retry(done.Err)
336 return
337 }
338 err := done.Err
339 if err != nil {
340 c.s.log.Info().Uint64("cast", c.cast).Err(err).Msg("cast frame failed")
341 }
342 if c.ending {
343 // The cast is over: the adapter must not queue this frame.
344 err = errors.Join(errCastStopped, err)
345 }
346 if c.waiting {
347 c.frameDone(err, done.Time)
348 }
349 if c.ending {
350 c.ending = false
351 c.finish()
352 }
353}
354
355// castFlip answers a flip of an output: the casts on it have something new
356// to show. An output cast always has; a window cast only when its window
357// committed since the last damage. Display goroutine; no allocation.
358func (s *Server) castFlip(output string, f *ports.FlipInfo) {
359 if len(s.castSessions) == 0 {
360 return
361 }
362 var at time.Time
363 for _, c := range s.castSessions {
364 switch c.src.kind {
365 case srcOutput:
366 if c.src.output.name() != output {
367 continue
368 }
369 case srcWindow:
370 if !c.dirty {
371 continue
372 }
373 if o, _, _, ok := c.geometry(); !ok || o.name() != output {
374 continue
375 }
376 default:
377 continue
378 }
379 if at.IsZero() {
380 at = flipTime(f)
381 }
382 c.dirty = false
383 c.push(ports.CastEvent{Kind: ports.CastDamage, Time: at})
384 }
385}
386
387// flipTime is when a flip happened, on CLOCK_MONOTONIC as capture times are.
388func flipTime(f *ports.FlipInfo) time.Time {
389 if f.When > 0 {
390 return time.Unix(0, int64(f.When))
391 }
392 var ts unix.Timespec
393 if unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) != nil {
394 return time.Time{}
395 }
396 return time.Unix(ts.Sec, ts.Nsec)
397}
398
399// castCommitted marks the casts of a window that committed content. A popup
400// is drawn with its toplevel (captureWindowScene), so its commits, map,
401// unmap and reposition mark the casts of the toplevel it hangs from, found
402// through the same parent chain as toplevelRoot.
403func (s *Server) castCommitted(id ports.WindowID) {
404 if len(s.castSessions) == 0 {
405 return
406 }
407 // A popup chain is as deep as menus nest; the bound only guards a cycle.
408 for range 64 {
409 w := s.windows[id]
410 if w == nil || w.popup == nil || w.popup.parent == nil {
411 break
412 }
413 id = w.popup.parent.id
414 }
415 for _, c := range s.castSessions {
416 if c.src.kind == srcWindow && c.src.window == id {
417 c.dirty = true
418 }
419 }
420}
421
422// forwardCasts moves the adapter's commands to the display goroutine through
423// castRing, in order; a full ring blocks this goroutine, never the display.
424func (s *Server) forwardCasts(ctxDone <-chan struct{}) {
425 for {
426 select {
427 case <-ctxDone:
428 return
429 case <-s.display.Stopped():
430 return
431 case cmd, ok := <-s.channels.CastCommands:
432 if !ok {
433 return
434 }
435 if !s.castRing.Push(cmd) {
436 return
437 }
438 }
439 }
440}
441
442// castInventoryChanged schedules a publication of the inventory after the
443// outputs, the titles, the app ids or the mapped windows may have changed.
444// Changes made in one display loop turn share one publication.
445func (s *Server) castInventoryChanged() {
446 if s.casts == nil || s.castInvPosted {
447 return
448 }
449 s.castInvPosted = true
450 s.display.Post(s.castInvFn)
451}
452
453// publishCastInventory sends the inventory when it differs from the last one.
454func (s *Server) publishCastInventory() {
455 s.castInvPosted = false
456 inv := s.castInventory()
457 if s.castInvSent && reflect.DeepEqual(inv, s.castInvLast) {
458 return
459 }
460 // The receiver owns what it is given: keep a copy of our own to compare.
461 s.castInvLast, s.castInvSent = inv, true
462 s.casts.Inventory(s.castInventory())
463}
464
465// castInventory is the outputs and, unless the session is protected, the
466// mapped toplevels now.
467func (s *Server) castInventory() ports.CastInventory {
468 inv := ports.CastInventory{Focused: s.focusedOutput, Outputs: make([]ports.CastOutput, 0, len(s.outputs))}
469 for _, o := range s.outputs {
470 p := o.place
471 out := ports.CastOutput{Info: p.Info, Current: -1, X: p.X, Y: p.Y, Width: p.Width, Height: p.Height, Scale: p.Scale, Transform: p.Transform}
472 for _, h := range s.outputHeads.Heads {
473 if h.Info.Name == p.Info.Name {
474 out.Modes = slices.Clone(h.Modes)
475 if h.Current != nil {
476 out.Current = slices.Index(out.Modes, *h.Current)
477 }
478 }
479 }
480 if out.Current < 0 {
481 // No head inventory (headless) or the mode is not listed: the
482 // mode the output runs.
483 mode := ports.OutputMode{Width: p.Info.Width, Height: p.Info.Height, RefreshMilli: p.Info.RefreshMilli, Preferred: len(out.Modes) == 0}
484 if out.Current = slices.IndexFunc(out.Modes, func(m ports.OutputMode) bool {
485 return m.Width == mode.Width && m.Height == mode.Height && m.RefreshMilli == mode.RefreshMilli
486 }); out.Current < 0 {
487 out.Modes, out.Current = append(out.Modes, mode), len(out.Modes)
488 }
489 }
490 inv.Outputs = append(inv.Outputs, out)
491 }
492 if s.protected() {
493 return inv
494 }
495 for _, id := range s.mappedToplevels() {
496 w := s.windows[id]
497 inv.Windows = append(inv.Windows, ports.CastWindow{ID: id, AppID: w.appID, Title: w.title})
498 }
499 return inv
500}