1package core
2
3import (
4 "context"
5 "slices"
6 "time"
7
8 "github.com/bnema/neferwl/internal/ports"
9)
10
11// Capture state (ports/capture.go). Only the core goroutine touches it.
12//
13// Things that live here:
14// - the registered ext-image-copy-capture sessions, decided on every publish
15// (target, hidden workspace, end);
16// - the one exclusion, whose HUD layers stay visible over a fullscreen
17// window and are left out of its session's frames;
18// - the workspace rendered off screen for a session (Scene.CaptureScene);
19// - the capture indicator (capindicator below): what shows on screen that
20// something is captured, whoever captures.
21//
22// With no session, no exclusion and no flash none of this allocates: the
23// scenes carry no Capture and no CaptureIndicators.
24
25// captureState is everything core keeps about captures. Methods on it touch
26// only this state; those that need screens, layers or popups stay on Core.
27type captureState struct {
28 sessions []*capSession
29 excl *capExclusion // the one live exclusion, nil when none
30 // flashes are the targets of captured frames still marked; timerC fires
31 // at the earliest of them (capindicator.go).
32 flashes []capFlash
33 timerC <-chan time.Time
34 timerStop func() bool
35 // scratch, states and marks are reused by every publish.
36 scratch capView
37 states []ports.CaptureSessionState
38 marks []ports.CaptureIndicator
39}
40
41func (s *captureState) session(id uint64) *capSession {
42 for _, cs := range s.sessions {
43 if cs.open.ID == id {
44 return cs
45 }
46 }
47 return nil
48}
49
50// idle reports whether nothing is captured or marked: scenes carry no
51// capture state.
52func (s *captureState) idle() bool {
53 return len(s.sessions) == 0 && len(s.flashes) == 0
54}
55
56// capTarget is what a capture covers, as asked.
57type capTarget struct {
58 output string
59 workspace uint64
60 rect Rect // output-local logical; zero is the whole output or frame
61}
62
63type capSession struct {
64 open ports.CaptureSessionOpen
65 last ports.CaptureSessionState
66 hasLast bool
67 // recording: the session produced a frame. Its target shows the
68 // indicator for as long as the session lives.
69 recording bool
70}
71
72func (s *capSession) target() capTarget {
73 return capTarget{output: s.open.Output, workspace: s.open.Workspace, rect: s.open.Region}
74}
75
76// capExclusion is the one live exclusion.
77type capExclusion struct {
78 session uint64
79 layers []WindowID // attached layer surfaces, at most ports.MaxExclusionLayers
80 // retained are detached layer surfaces still listed by a screen: they
81 // stay excluded until a LayerChanged stops listing them, so a HUD that
82 // is destroyed is never captured during its last frame.
83 retained []WindowID
84 // ended: the exclusion object was destroyed and the session lives on.
85 // Its layers are retained only: still excluded and fenced (frames of the
86 // session keep leaving them out) until no screen lists them, then the
87 // exclusion is dropped. It never shows layers over a fullscreen window.
88 ended bool
89 // keep is the set of attached layers, shared by every screen
90 // (screen.capture) and rebuilt only when the attached set changes.
91 keep map[WindowID]bool
92 rev uint64 // revision fence, bumped when what a frame must leave out changes
93 key capExclusionKey
94 hasKey bool
95 // scratch is the list captureExcluded builds.
96 scratch []WindowID
97}
98
99// capExclusionKey is what the revision fence follows.
100type capExclusionKey struct {
101 excluded []WindowID
102 layers []WindowID
103 target ports.CaptureSessionState
104}
105
106// capView is what one evaluation decided, for the scenes of this publish.
107type capView struct {
108 hidden *Workspace // captured off screen, nil when none
109 hiddenScr *screen
110 hiddenID uint64
111 excluded []WindowID
112 exclusion *capExclusion
113}
114
115// capResolved is a target located on the current screens.
116type capResolved struct {
117 sc *screen
118 ws *Workspace
119 rect Rect // clipped, output-local logical
120 hidden bool
121}
122
123// capResolve locates a target. A zero reason is success.
124func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {
125 var r capResolved
126 if t.workspace != 0 {
127 sc, w := c.workspaceByID(t.workspace)
128 if w == nil || sc.name() == "" {
129 return r, ports.CaptureReasonWorkspaceGone
130 }
131 r.sc, r.ws, r.rect = sc, w, w.Output
132 r.hidden = sc.mon.Current() != w || sc.mon.ov.open
133 return r, ports.CaptureReasonNone
134 }
135 i := c.screenIndex(t.output)
136 if i < 0 || t.output == "" {
137 return r, ports.CaptureReasonOutputGone
138 }
139 r.sc = c.screens[i]
140 r.rect = r.sc.mon.Output()
141 if t.rect.W != 0 || t.rect.H != 0 {
142 r.rect = intersect(r.rect, t.rect)
143 }
144 if r.rect.W <= 0 || r.rect.H <= 0 {
145 return r, ports.CaptureReasonInvalidRegion
146 }
147 return r, ports.CaptureReasonNone
148}
149
150// captureOpen registers a session.
151func (c *Core) captureOpen(v ports.CaptureSessionOpen) {
152 c.capt.sessions = append(c.capt.sessions, &capSession{open: v})
153}
154
155// captureClose forgets a session and its exclusion. Its flashes stay.
156func (c *Core) captureClose(id uint64) {
157 c.capt.sessions = slices.DeleteFunc(c.capt.sessions, func(s *capSession) bool { return s.open.ID == id })
158 if c.capt.excl != nil && c.capt.excl.session == id {
159 c.dropExclusion()
160 }
161}
162
163// captureExclusionBegin starts the exclusion of a session. Wayland refused
164// it already when another one was live.
165func (c *Core) captureExclusionBegin(v ports.CaptureExclusionBegin) {
166 if c.capt.session(v.Session) == nil {
167 return
168 }
169 if old := c.capt.excl; old != nil {
170 if !old.ended {
171 return
172 }
173 // An ended exclusion still excludes its listed HUD layers: a new one
174 // inherits them, so they are left out until no screen lists them.
175 c.capt.excl = &capExclusion{session: v.Session, retained: old.retained, rev: old.rev}
176 return
177 }
178 c.capt.excl = &capExclusion{session: v.Session}
179}
180
181// captureExclusionLayer attaches or detaches a layer surface.
182func (c *Core) captureExclusionLayer(v ports.CaptureExclusionLayer) {
183 e := c.capt.excl
184 if e == nil || e.ended || e.session != v.Session {
185 return
186 }
187 i := slices.Index(e.layers, v.Layer)
188 switch {
189 case v.Attached && i < 0 && len(e.layers) < ports.MaxExclusionLayers:
190 e.layers = append(e.layers, v.Layer)
191 e.keep = nil
192 c.clampCaptureKeyboard()
193 case !v.Attached && i >= 0:
194 e.layers = slices.Delete(e.layers, i, i+1)
195 e.keep = nil
196 if c.layerListed(v.Layer) {
197 e.retained = append(e.retained, v.Layer)
198 }
199 }
200}
201
202// captureExclusionEnd ends the exclusion of a session that lives on. Its
203// layers that a screen still lists stay excluded until none does (the
204// retained set): the recorder's frames never show a HUD that just ended.
205func (c *Core) captureExclusionEnd(session uint64) {
206 e := c.capt.excl
207 if e == nil || e.ended || e.session != session {
208 return
209 }
210 for _, id := range e.layers {
211 if c.layerListed(id) && !slices.Contains(e.retained, id) {
212 e.retained = append(e.retained, id)
213 }
214 }
215 e.layers, e.keep, e.ended = nil, nil, true
216 c.pruneRetained()
217 if c.capt.excl != nil {
218 // Attached layers no longer stay over a fullscreen window.
219 for _, sc := range c.screens {
220 sc.capture = nil
221 }
222 }
223}
224
225// dropCaptureSessions forgets every session and the exclusion: the session is
226// protected, no capture is served, and wayland has stopped them all.
227func (c *Core) dropCaptureSessions() {
228 c.capt.sessions = nil
229 // No capture indicator outlives the lock: nothing is drawn for a capture
230 // that can no longer happen, and none is left to show after the unlock.
231 c.capt.flashes = nil
232 c.stopCaptureTimer()
233 c.dropExclusion()
234}
235
236func (c *Core) dropExclusion() {
237 c.capt.excl = nil
238 c.configures.cw.reset()
239 for _, sc := range c.screens {
240 sc.capture = nil
241 }
242}
243
244// layerListed reports whether a screen still lists the layer surface.
245func (c *Core) layerListed(id WindowID) bool {
246 for _, sc := range c.screens {
247 for _, l := range sc.layers {
248 if l.ID == id {
249 return true
250 }
251 }
252 }
253 return false
254}
255
256// pruneRetained forgets the detached layers no screen lists any more, and
257// the ended exclusion with the last of them.
258func (c *Core) pruneRetained() {
259 e := c.capt.excl
260 if e == nil {
261 return
262 }
263 e.retained = slices.DeleteFunc(e.retained, func(id WindowID) bool { return !c.layerListed(id) })
264 if e.ended && len(e.retained) == 0 {
265 c.dropExclusion()
266 }
267}
268
269// exclusionKeep is the layers of the exclusion that stay visible over a
270// fullscreen window. It is built once per change of the attached set and
271// shared, read-only, by every screen.
272func (c *Core) exclusionKeep(e *capExclusion) map[WindowID]bool {
273 if e.ended {
274 return nil
275 }
276 if e.keep == nil {
277 e.keep = make(map[WindowID]bool, len(e.layers))
278 for _, id := range e.layers {
279 e.keep[id] = true
280 }
281 }
282 return e.keep
283}
284
285// clampCaptureKeyboard gives every attached layer surface no keyboard
286// interactivity at all, whatever it asks for (exclusive or on demand): a
287// capture HUD never takes or steals the keyboard. Run whenever the layer
288// list or the attached set changes.
289func (c *Core) clampCaptureKeyboard() {
290 e := c.capt.excl
291 if e == nil {
292 return
293 }
294 for _, sc := range c.screens {
295 for i := range sc.layers {
296 if sc.layers[i].Keyboard != 0 && (slices.Contains(e.layers, sc.layers[i].ID) || slices.Contains(e.retained, sc.layers[i].ID)) {
297 sc.layers[i].Keyboard = 0
298 }
299 }
300 }
301}
302
303// excluded lists the attached layers and every popup hanging from one of
304// them, sorted. Layers not mapped yet are listed too: they are excluded from
305// their first frame.
306// The result is scratch of the exclusion, valid until the next call.
307func (c *Core) captureExcluded(e *capExclusion) []WindowID {
308 out := append(append(e.scratch[:0], e.layers...), e.retained...)
309 for id := range c.popups {
310 if root := c.popupRoot(id); slices.Contains(e.layers, root) || slices.Contains(e.retained, root) {
311 out = append(out, id)
312 }
313 }
314 slices.Sort(out)
315 e.scratch = out
316 return out
317}
318
319// captureEvaluate decides every session against the current screens, sends
320// the states that changed and returns the view of this publish; nil when
321// there is no session and no exclusion. A terminal state
322// is sent once and the session is forgotten.
323func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
324 if len(c.capt.sessions) == 0 && c.capt.excl == nil {
325 return nil, nil
326 }
327 c.pruneRetained()
328 // The scratch is the view and the states of this evaluation; nothing the
329 // scenes or the commands keep points into it.
330 v := &c.capt.scratch
331 *v = capView{}
332 // Decide each session. The live ones are kept in place.
333 states := c.capt.states[:0]
334 alive := c.capt.sessions[:0]
335 for i, s := range c.capt.sessions {
336 st := ports.CaptureSessionState{ID: s.open.ID}
337 r, reason := c.capResolve(s.target())
338 if reason == ports.CaptureReasonNone {
339 st.Output, st.Rect, st.Workspace, st.Hidden = r.sc.name(), r.rect, s.open.Workspace, r.hidden
340 if r.hidden && v.hiddenID == 0 {
341 v.hidden, v.hiddenScr, v.hiddenID = r.ws, r.sc, s.open.Workspace
342 }
343 // One hidden workspace is rendered at a time.
344 st.Active = !r.hidden || v.hiddenID == s.open.Workspace
345 }
346 st.Reason = reason
347 states = append(states, st)
348 if reason != ports.CaptureReasonNone {
349 if err := c.command(ctx, st); err != nil {
350 c.capt.sessions = append(alive, c.capt.sessions[i:]...)
351 return nil, err
352 }
353 if c.capt.excl != nil && c.capt.excl.session == s.open.ID {
354 c.dropExclusion()
355 }
356 continue
357 }
358 alive = append(alive, s)
359 }
360 clear(c.capt.sessions[len(alive):])
361 c.capt.sessions = alive
362 c.capt.states = states
363 // Exclusion.
364 if e := c.capt.excl; e != nil {
365 ex := c.captureExcluded(e)
366 if len(ex) > ports.MaxCaptureExcluded {
367 // Fail closed: the session cannot leave them all out.
368 st := ports.CaptureSessionState{ID: e.session, Reason: ports.CaptureReasonTooManyExcluded}
369 if err := c.command(ctx, st); err != nil {
370 return nil, err
371 }
372 c.capt.sessions = slices.DeleteFunc(c.capt.sessions, func(s *capSession) bool { return s.open.ID == e.session })
373 c.dropExclusion()
374 } else {
375 v.exclusion = e
376 keep := c.exclusionKeep(e)
377 for _, sc := range c.screens {
378 sc.capture = keep
379 }
380 var st ports.CaptureSessionState
381 for _, x := range states {
382 if x.ID == e.session {
383 st = x
384 }
385 }
386 if !e.hasKey || !slices.Equal(e.key.excluded, ex) || !slices.Equal(e.key.layers, e.layers) || !sameCaptureState(e.key.target, st) {
387 e.rev++
388 e.key = capExclusionKey{excluded: slices.Clone(ex), layers: slices.Clone(e.layers), target: st}
389 e.hasKey = true
390 }
391 // What the scenes carry is the key's list, shared while it holds.
392 v.excluded = e.key.excluded
393 }
394 }
395 // Tell wayland what changed.
396 for _, s := range c.capt.sessions {
397 var st ports.CaptureSessionState
398 for _, x := range states {
399 if x.ID == s.open.ID {
400 st = x
401 }
402 }
403 if e := c.capt.excl; e != nil && e.session == s.open.ID {
404 st.Exclusion, st.Revision = true, e.rev
405 // The list is sent, read by another goroutine: shared, never
406 // rebuilt, while the attached set is the same.
407 if s.hasLast && slices.Equal(s.last.Layers, e.layers) {
408 st.Layers = s.last.Layers
409 } else {
410 st.Layers = slices.Clone(e.layers)
411 }
412 }
413 if !s.hasLast || !sameCaptureState(s.last, st) {
414 s.last, s.hasLast = st, true
415 if err := c.command(ctx, st); err != nil {
416 return nil, err
417 }
418 }
419 }
420 return v, nil
421}
422
423// sameCaptureState reports whether two session states say the same.
424func sameCaptureState(a, b ports.CaptureSessionState) bool {
425 return a.ID == b.ID && a.Output == b.Output && a.Rect == b.Rect && a.Workspace == b.Workspace &&
426 a.Hidden == b.Hidden && a.Active == b.Active && a.Reason == b.Reason &&
427 a.Exclusion == b.Exclusion && a.Revision == b.Revision && slices.Equal(a.Layers, b.Layers)
428}
429
430// captureSceneFor is the capture state of one screen's scene; nil when
431// there is no session. Shown is the workspace drawn as itself on screen.
432func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture {
433 if v == nil || len(c.capt.sessions) == 0 && v.exclusion == nil {
434 return nil
435 }
436 want := ports.SceneCapture{}
437 if !sc.mon.ov.open {
438 want.Shown = sc.mon.Current().ID
439 }
440 if e := v.exclusion; e != nil {
441 want.Session, want.Revision, want.Excluded = e.session, e.rev, v.excluded
442 }
443 if v.hiddenScr == sc {
444 want.Workspace = v.hiddenID
445 }
446 // The scene is read by the output owner and never changed: the last one
447 // is shared while it says the same.
448 if p := sc.capScene; p != nil && p.Shown == want.Shown && p.Session == want.Session && p.Revision == want.Revision &&
449 p.Workspace == want.Workspace && slices.Equal(p.Excluded, want.Excluded) {
450 return p
451 }
452 shared := want
453 sc.capScene = &shared
454 return sc.capScene
455}
456
457// workspaceByID finds a workspace, numbered or hidden, on any screen.
458func (c *Core) workspaceByID(id uint64) (*screen, *Workspace) {
459 for _, sc := range c.screens {
460 for w := range sc.mon.all() {
461 if w.ID == id {
462 return sc, w
463 }
464 }
465 }
466 return nil, nil
467}