detached 9fb27a4

feat(wayland): serve screencast casts with damage pacing and a window and output inventory

commit
9fb27a481384f8df6155b2e96bce86af1920fce1
author
brice · · 2026-10-14 19:31 UTC
committer
brice · · 2026-10-14 21:39 UTC
parent
2c2814d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1193 -5
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations)$$' -count=1
72+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations)$$' -count=1
7373 check: vet test arch fakes-check log-check perf-check
7474
7575 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -69,7 +69,7 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.70 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:71 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations)$$' -count=172+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations)$$' -count=1
73 check: vet test arch fakes-check log-check perf-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M internal/adapters/wayland/foreigntoplevel.go +1 -0
@@ -206,6 +206,7 @@ func (s *Server) toplevelClosed(id ports.WindowID) {
206206 l.closed(id)
207207 }
208208 s.windowCaptureGone(id)
209+ s.castInventoryChanged()
209210 }
210211
211212 // refreshToplevels reconciles inventory after output changes or unlock:
@@ -206,6 +206,7 @@ func (s *Server) toplevelClosed(id ports.WindowID) {
206 l.closed(id)206 l.closed(id)
207 }207 }
208 s.windowCaptureGone(id)208 s.windowCaptureGone(id)
209+ s.castInventoryChanged()
209 }210 }
210 211
211 // refreshToplevels reconciles inventory after output changes or unlock:212 // refreshToplevels reconciles inventory after output changes or unlock:
M internal/adapters/wayland/frames.go +1 -0
@@ -155,6 +155,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent
155155 if p.Flip != nil {
156156 s.flips[p.Output] = *p.Flip
157157 s.presentFlip(p.Output, p.Flip)
158+ s.castFlip(p.Output, p.Flip)
158159 }
159160 }
160161 s.dropFeedbacks(time.Now())
@@ -155,6 +155,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent
155 if p.Flip != nil {155 if p.Flip != nil {
156 s.flips[p.Output] = *p.Flip156 s.flips[p.Output] = *p.Flip
157 s.presentFlip(p.Output, p.Flip)157 s.presentFlip(p.Output, p.Flip)
158+ s.castFlip(p.Output, p.Flip)
158 }159 }
159 }160 }
160 s.dropFeedbacks(time.Now())161 s.dropFeedbacks(time.Now())
M internal/adapters/wayland/imagecopy.go +24 -0
@@ -113,6 +113,12 @@ func (s *Server) windowCaptureGone(id ports.WindowID) {
113113 c.end(true, true)
114114 }
115115 }
116+ for _, c := range s.castSessions {
117+ if c.src.kind == srcWindow && c.src.window == id {
118+ s.log.Info().Uint64("cast", c.cast).Str("reason", string(ports.CaptureReasonWindowGone)).Msg("cast end")
119+ c.end(ports.CaptureReasonWindowGone, nil, true, true)
120+ }
121+ }
116122 }
117123
118124 type extraSources struct{ s *Server }
@@ -447,6 +453,9 @@ func (s *Server) refreshSessions() {
447453 for c := range s.captureSessions {
448454 c.refresh()
449455 }
456+ for _, c := range s.castSessions {
457+ c.refresh()
458+ }
450459 }
451460
452461 // endCaptureSessions stops every session, and its exclusion with it, when
@@ -459,6 +468,11 @@ func (s *Server) endCaptureSessions() {
459468 }
460469 c.end(true, true)
461470 }
471+ for _, c := range s.castSessions {
472+ s.log.Info().Uint64("cast", c.cast).Str("reason", "session-protected").Msg("cast end")
473+ c.end(ports.CaptureReasonNone, errCastProtected, true, true)
474+ }
475+ s.castInventoryChanged()
462476 }
463477
464478 // outputGone ends the sessions of an unplugged output without waiting for
@@ -470,10 +484,20 @@ func (s *Server) outputGone(o *output) {
470484 c.end(true, true)
471485 }
472486 }
487+ for _, c := range s.castSessions {
488+ if c.src.kind == srcOutput && c.src.output == o {
489+ s.log.Info().Uint64("cast", c.cast).Str("reason", string(ports.CaptureReasonOutputGone)).Msg("cast end")
490+ c.end(ports.CaptureReasonOutputGone, nil, true, true)
491+ }
492+ }
473493 }
474494
475495 // captureState applies what core decided about a session.
476496 func (s *Server) captureState(st ports.CaptureSessionState) {
497+ if c := s.castByCore[st.ID]; c != nil {
498+ c.state(st)
499+ return
500+ }
477501 c := s.sessionsByID[st.ID]
478502 if c == nil {
479503 return
@@ -113,6 +113,12 @@ func (s *Server) windowCaptureGone(id ports.WindowID) {
113 c.end(true, true)113 c.end(true, true)
114 }114 }
115 }115 }
116+ for _, c := range s.castSessions {
117+ if c.src.kind == srcWindow && c.src.window == id {
118+ s.log.Info().Uint64("cast", c.cast).Str("reason", string(ports.CaptureReasonWindowGone)).Msg("cast end")
119+ c.end(ports.CaptureReasonWindowGone, nil, true, true)
120+ }
121+ }
116 }122 }
117 123
118 type extraSources struct{ s *Server }124 type extraSources struct{ s *Server }
@@ -447,6 +453,9 @@ func (s *Server) refreshSessions() {
447 for c := range s.captureSessions {453 for c := range s.captureSessions {
448 c.refresh()454 c.refresh()
449 }455 }
456+ for _, c := range s.castSessions {
457+ c.refresh()
458+ }
450 }459 }
451 460
452 // endCaptureSessions stops every session, and its exclusion with it, when461 // endCaptureSessions stops every session, and its exclusion with it, when
@@ -459,6 +468,11 @@ func (s *Server) endCaptureSessions() {
459 }468 }
460 c.end(true, true)469 c.end(true, true)
461 }470 }
471+ for _, c := range s.castSessions {
472+ s.log.Info().Uint64("cast", c.cast).Str("reason", "session-protected").Msg("cast end")
473+ c.end(ports.CaptureReasonNone, errCastProtected, true, true)
474+ }
475+ s.castInventoryChanged()
462 }476 }
463 477
464 // outputGone ends the sessions of an unplugged output without waiting for478 // outputGone ends the sessions of an unplugged output without waiting for
@@ -470,10 +484,20 @@ func (s *Server) outputGone(o *output) {
470 c.end(true, true)484 c.end(true, true)
471 }485 }
472 }486 }
487+ for _, c := range s.castSessions {
488+ if c.src.kind == srcOutput && c.src.output == o {
489+ s.log.Info().Uint64("cast", c.cast).Str("reason", string(ports.CaptureReasonOutputGone)).Msg("cast end")
490+ c.end(ports.CaptureReasonOutputGone, nil, true, true)
491+ }
492+ }
473 }493 }
474 494
475 // captureState applies what core decided about a session.495 // captureState applies what core decided about a session.
476 func (s *Server) captureState(st ports.CaptureSessionState) {496 func (s *Server) captureState(st ports.CaptureSessionState) {
497+ if c := s.castByCore[st.ID]; c != nil {
498+ c.state(st)
499+ return
500+ }
477 c := s.sessionsByID[st.ID]501 c := s.sessionsByID[st.ID]
478 if c == nil {502 if c == nil {
479 return503 return
M internal/adapters/wayland/outputmanagement.go +1 -0
@@ -189,6 +189,7 @@ func (s *Server) setOutputHeads(h ports.OutputHeads) {
189189 return
190190 }
191191 s.outputHeads = h
192+ s.castInventoryChanged()
192193 s.refreshOutputManagers()
193194 }
194195 func (s *Server) refreshOutputManagers() {
@@ -189,6 +189,7 @@ func (s *Server) setOutputHeads(h ports.OutputHeads) {
189 return189 return
190 }190 }
191 s.outputHeads = h191 s.outputHeads = h
192+ s.castInventoryChanged()
192 s.refreshOutputManagers()193 s.refreshOutputManagers()
193 }194 }
194 func (s *Server) refreshOutputManagers() {195 func (s *Server) refreshOutputManagers() {
M internal/adapters/wayland/outputs.go +4 -0
@@ -160,11 +160,15 @@ func (s *Server) addOutput(p ports.OutputPlacement) {
160160 // changed ones resent.
161161 func (s *Server) setOutputs(c ports.SetOutputs) {
162162 changed := !slices.Equal(s.outputPlaces, c.Outputs)
163+ focusChanged := s.focusedOutput != c.Focused
163164 s.outputPlaces = append(ports.Layout(nil), c.Outputs...)
164165 defer func() {
165166 if changed {
166167 s.refreshOutputManagers()
167168 }
169+ if changed || focusChanged {
170+ s.castInventoryChanged()
171+ }
168172 }()
169173 s.focusedOutput = c.Focused
170174 want := map[string]ports.OutputPlacement{}
@@ -160,11 +160,15 @@ func (s *Server) addOutput(p ports.OutputPlacement) {
160 // changed ones resent.160 // changed ones resent.
161 func (s *Server) setOutputs(c ports.SetOutputs) {161 func (s *Server) setOutputs(c ports.SetOutputs) {
162 changed := !slices.Equal(s.outputPlaces, c.Outputs)162 changed := !slices.Equal(s.outputPlaces, c.Outputs)
163+ focusChanged := s.focusedOutput != c.Focused
163 s.outputPlaces = append(ports.Layout(nil), c.Outputs...)164 s.outputPlaces = append(ports.Layout(nil), c.Outputs...)
164 defer func() {165 defer func() {
165 if changed {166 if changed {
166 s.refreshOutputManagers()167 s.refreshOutputManagers()
167 }168 }
169+ if changed || focusChanged {
170+ s.castInventoryChanged()
171+ }
168 }()172 }()
169 s.focusedOutput = c.Focused173 s.focusedOutput = c.Focused
170 want := map[string]ports.OutputPlacement{}174 want := map[string]ports.OutputPlacement{}
A internal/adapters/wayland/screencast.go +486 -0
@@ -0,0 +1,486 @@
1+package wayland
2+
3+import (
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.
31+const maxCasts = 8
32+
33+// castRingSize bounds the cast commands waiting for the display goroutine.
34+const castRingSize = 64
35+
36+var (
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.
48+type 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.
80+func (c *castSession) postRetry() { c.s.display.Post(c.retryFn) }
81+
82+// push hands an event to the adapter.
83+func (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.
89+func (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.
96+func (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.
121+func (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.
126+func (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.
132+func (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.
179+func (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.
189+func (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.
211+func (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.
237+func (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.
244+func (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.
251+func (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).
259+func (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.
274+func (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.
302+func (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.
332+func (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.
358+func (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.
388+func 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.
400+func (s *Server) castCommitted(id ports.WindowID) {
401+ for _, c := range s.castSessions {
402+ if c.src.kind == srcWindow && c.src.window == id {
403+ c.dirty = true
404+ }
405+ }
406+}
407+
408+// forwardCasts moves the adapter's commands to the display goroutine through
409+// castRing, in order; a full ring blocks this goroutine, never the display.
410+func (s *Server) forwardCasts(ctxDone <-chan struct{}) {
411+ for {
412+ select {
413+ case <-ctxDone:
414+ return
415+ case <-s.display.Stopped():
416+ return
417+ case cmd, ok := <-s.channels.CastCommands:
418+ if !ok {
419+ return
420+ }
421+ if !s.castRing.Push(cmd) {
422+ return
423+ }
424+ }
425+ }
426+}
427+
428+// castInventoryChanged schedules a publication of the inventory after the
429+// outputs, the titles, the app ids or the mapped windows may have changed.
430+// Changes made in one display loop turn share one publication.
431+func (s *Server) castInventoryChanged() {
432+ if s.casts == nil || s.castInvPosted {
433+ return
434+ }
435+ s.castInvPosted = true
436+ s.display.Post(s.castInvFn)
437+}
438+
439+// publishCastInventory sends the inventory when it differs from the last one.
440+func (s *Server) publishCastInventory() {
441+ s.castInvPosted = false
442+ inv := s.castInventory()
443+ if s.castInvSent && reflect.DeepEqual(inv, s.castInvLast) {
444+ return
445+ }
446+ // The receiver owns what it is given: keep a copy of our own to compare.
447+ s.castInvLast, s.castInvSent = inv, true
448+ s.casts.Inventory(s.castInventory())
449+}
450+
451+// castInventory is the outputs and, unless the session is protected, the
452+// mapped toplevels now.
453+func (s *Server) castInventory() ports.CastInventory {
454+ inv := ports.CastInventory{Focused: s.focusedOutput, Outputs: make([]ports.CastOutput, 0, len(s.outputs))}
455+ for _, o := range s.outputs {
456+ p := o.place
457+ 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}
458+ for _, h := range s.outputHeads.Heads {
459+ if h.Info.Name == p.Info.Name {
460+ out.Modes = slices.Clone(h.Modes)
461+ if h.Current != nil {
462+ out.Current = slices.Index(out.Modes, *h.Current)
463+ }
464+ }
465+ }
466+ if out.Current < 0 {
467+ // No head inventory (headless) or the mode is not listed: the
468+ // mode the output runs.
469+ mode := ports.OutputMode{Width: p.Info.Width, Height: p.Info.Height, RefreshMilli: p.Info.RefreshMilli, Preferred: len(out.Modes) == 0}
470+ if out.Current = slices.IndexFunc(out.Modes, func(m ports.OutputMode) bool {
471+ return m.Width == mode.Width && m.Height == mode.Height && m.RefreshMilli == mode.RefreshMilli
472+ }); out.Current < 0 {
473+ out.Modes, out.Current = append(out.Modes, mode), len(out.Modes)
474+ }
475+ }
476+ inv.Outputs = append(inv.Outputs, out)
477+ }
478+ if s.protected() {
479+ return inv
480+ }
481+ for _, id := range s.mappedToplevels() {
482+ w := s.windows[id]
483+ inv.Windows = append(inv.Windows, ports.CastWindow{ID: id, AppID: w.appID, Title: w.title})
484+ }
485+ return inv
486+}
@@ -0,0 +1,486 @@
1+package wayland
2+
3+import (
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.
31+const maxCasts = 8
32+
33+// castRingSize bounds the cast commands waiting for the display goroutine.
34+const castRingSize = 64
35+
36+var (
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.
48+type 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.
80+func (c *castSession) postRetry() { c.s.display.Post(c.retryFn) }
81+
82+// push hands an event to the adapter.
83+func (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.
89+func (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.
96+func (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.
121+func (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.
126+func (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.
132+func (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.
179+func (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.
189+func (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.
211+func (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.
237+func (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.
244+func (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.
251+func (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).
259+func (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.
274+func (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.
302+func (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.
332+func (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.
358+func (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.
388+func 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.
400+func (s *Server) castCommitted(id ports.WindowID) {
401+ for _, c := range s.castSessions {
402+ if c.src.kind == srcWindow && c.src.window == id {
403+ c.dirty = true
404+ }
405+ }
406+}
407+
408+// forwardCasts moves the adapter's commands to the display goroutine through
409+// castRing, in order; a full ring blocks this goroutine, never the display.
410+func (s *Server) forwardCasts(ctxDone <-chan struct{}) {
411+ for {
412+ select {
413+ case <-ctxDone:
414+ return
415+ case <-s.display.Stopped():
416+ return
417+ case cmd, ok := <-s.channels.CastCommands:
418+ if !ok {
419+ return
420+ }
421+ if !s.castRing.Push(cmd) {
422+ return
423+ }
424+ }
425+ }
426+}
427+
428+// castInventoryChanged schedules a publication of the inventory after the
429+// outputs, the titles, the app ids or the mapped windows may have changed.
430+// Changes made in one display loop turn share one publication.
431+func (s *Server) castInventoryChanged() {
432+ if s.casts == nil || s.castInvPosted {
433+ return
434+ }
435+ s.castInvPosted = true
436+ s.display.Post(s.castInvFn)
437+}
438+
439+// publishCastInventory sends the inventory when it differs from the last one.
440+func (s *Server) publishCastInventory() {
441+ s.castInvPosted = false
442+ inv := s.castInventory()
443+ if s.castInvSent && reflect.DeepEqual(inv, s.castInvLast) {
444+ return
445+ }
446+ // The receiver owns what it is given: keep a copy of our own to compare.
447+ s.castInvLast, s.castInvSent = inv, true
448+ s.casts.Inventory(s.castInventory())
449+}
450+
451+// castInventory is the outputs and, unless the session is protected, the
452+// mapped toplevels now.
453+func (s *Server) castInventory() ports.CastInventory {
454+ inv := ports.CastInventory{Focused: s.focusedOutput, Outputs: make([]ports.CastOutput, 0, len(s.outputs))}
455+ for _, o := range s.outputs {
456+ p := o.place
457+ 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}
458+ for _, h := range s.outputHeads.Heads {
459+ if h.Info.Name == p.Info.Name {
460+ out.Modes = slices.Clone(h.Modes)
461+ if h.Current != nil {
462+ out.Current = slices.Index(out.Modes, *h.Current)
463+ }
464+ }
465+ }
466+ if out.Current < 0 {
467+ // No head inventory (headless) or the mode is not listed: the
468+ // mode the output runs.
469+ mode := ports.OutputMode{Width: p.Info.Width, Height: p.Info.Height, RefreshMilli: p.Info.RefreshMilli, Preferred: len(out.Modes) == 0}
470+ if out.Current = slices.IndexFunc(out.Modes, func(m ports.OutputMode) bool {
471+ return m.Width == mode.Width && m.Height == mode.Height && m.RefreshMilli == mode.RefreshMilli
472+ }); out.Current < 0 {
473+ out.Modes, out.Current = append(out.Modes, mode), len(out.Modes)
474+ }
475+ }
476+ inv.Outputs = append(inv.Outputs, out)
477+ }
478+ if s.protected() {
479+ return inv
480+ }
481+ for _, id := range s.mappedToplevels() {
482+ w := s.windows[id]
483+ inv.Windows = append(inv.Windows, ports.CastWindow{ID: id, AppID: w.appID, Title: w.title})
484+ }
485+ return inv
486+}
A internal/adapters/wayland/screencast_test.go +637 -0
@@ -0,0 +1,637 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
11+ "github.com/bnema/neferwl/internal/adapters/logging"
12+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
13+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/stretchr/testify/mock"
16+)
17+
18+// castLayout: two small outputs, so frames are cheap and a flip of the other
19+// output can be told from a flip of the cast's.
20+var castLayout = ports.Layout{
21+ {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32}, Width: 64, Height: 32, Scale: 1},
22+ {Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 32, Height: 32}, X: 64, Width: 32, Height: 32, Scale: 1},
23+}
24+
25+// castHarness runs a server with the cast channels wired: the commands go in
26+// through casts, the events and inventories the server publishes are recorded
27+// by the generated CastSink mock into got and invs, and the capture requests
28+// and replies are the test's.
29+type castHarness struct {
30+ t *testing.T
31+ s *Server
32+ dir string
33+ events chan ports.ClientEvent
34+ commands chan ports.ClientCommand
35+ requests chan ports.CaptureRequest
36+ replies chan ports.CaptureDone
37+ casts chan ports.CastCommand
38+ got chan ports.CastEvent
39+ invs chan ports.CastInventory
40+ changes chan ports.SecurityState
41+}
42+
43+// newCastHarness starts the server; withSink false leaves Options.Casts nil
44+// (the allocation test measures the server alone).
45+func newCastHarness(t *testing.T, withSink bool) *castHarness {
46+ t.Helper()
47+ h := &castHarness{
48+ t: t,
49+ dir: t.TempDir(),
50+ events: make(chan ports.ClientEvent, 256),
51+ commands: make(chan ports.ClientCommand, 16),
52+ requests: make(chan ports.CaptureRequest, maxCaptureInflight),
53+ replies: make(chan ports.CaptureDone, maxCaptureInflight+2),
54+ casts: make(chan ports.CastCommand, 16),
55+ got: make(chan ports.CastEvent, 4096),
56+ invs: make(chan ports.CastInventory, 64),
57+ changes: make(chan ports.SecurityState, 16),
58+ }
59+ opts := Options{RuntimeDir: h.dir, Outputs: castLayout, Security: &sessionsecurity.Gate{}}
60+ if withSink {
61+ sink := portsmocks.NewMockCastSink(t)
62+ sink.EXPECT().Push(mock.Anything).RunAndReturn(func(ev ports.CastEvent) { h.got <- ev }).Maybe()
63+ sink.EXPECT().Inventory(mock.Anything).RunAndReturn(func(inv ports.CastInventory) { h.invs <- inv }).Maybe()
64+ opts.Casts = sink
65+ }
66+ ch := Channels{
67+ Events: h.events, Commands: h.commands, Captures: h.requests, Captured: h.replies, CastCommands: h.casts,
68+ SecurityChanges: h.changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16),
69+ }
70+ s, err := New(opts, ch, logging.For(context.Background(), "wayland"))
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ h.s = s
75+ relayContents(t, s)
76+ ctx, cancel := context.WithCancel(context.Background())
77+ done := make(chan error, 1)
78+ go func() { done <- s.Run(ctx) }()
79+ t.Cleanup(func() { cancel(); <-done })
80+ return h
81+}
82+
83+// sync returns once the display goroutine ran everything posted before.
84+func (h *castHarness) sync() {
85+ h.t.Helper()
86+ ran := make(chan struct{})
87+ h.s.display.Post(func() { close(ran) })
88+ select {
89+ case <-ran:
90+ case <-time.After(3 * time.Second):
91+ h.t.Fatal("display goroutine stalled")
92+ }
93+}
94+
95+// next is the next event pushed to the sink, which must have that kind.
96+func (h *castHarness) next(kind ports.CastEventKind) ports.CastEvent {
97+ h.t.Helper()
98+ select {
99+ case ev := <-h.got:
100+ if ev.Kind != kind {
101+ h.t.Fatalf("event %+v, want kind %d", ev, kind)
102+ }
103+ return ev
104+ case <-time.After(3 * time.Second):
105+ h.t.Fatalf("no cast event of kind %d", kind)
106+ return ports.CastEvent{}
107+ }
108+}
109+
110+// none says that no event is pending once the display goroutine caught up.
111+func (h *castHarness) none() {
112+ h.t.Helper()
113+ h.sync()
114+ select {
115+ case ev := <-h.got:
116+ h.t.Fatalf("unexpected cast event %+v", ev)
117+ default:
118+ }
119+}
120+
121+func (h *castHarness) request() ports.CaptureRequest {
122+ h.t.Helper()
123+ select {
124+ case r := <-h.requests:
125+ return r
126+ case <-time.After(3 * time.Second):
127+ h.t.Fatal("no capture request")
128+ return ports.CaptureRequest{}
129+ }
130+}
131+
132+// noRequest says that no capture was requested.
133+func (h *castHarness) noRequest(wait time.Duration) {
134+ h.t.Helper()
135+ select {
136+ case r := <-h.requests:
137+ h.t.Fatalf("unexpected capture request for cast session %d on %s", r.Session, r.Output)
138+ case <-time.After(wait):
139+ }
140+}
141+
142+// castClientEvent waits for the next ClientEvent of type T, skipping others.
143+func castClientEvent[T ports.ClientEvent](h *castHarness) T {
144+ h.t.Helper()
145+ for {
146+ select {
147+ case ev := <-h.events:
148+ if v, ok := ev.(T); ok {
149+ return v
150+ }
151+ case <-time.After(3 * time.Second):
152+ var zero T
153+ h.t.Fatalf("no %T", zero)
154+ return zero
155+ }
156+ }
157+}
158+
159+// complete answers a capture request the way an output does.
160+func (h *castHarness) complete(req ports.CaptureRequest, err error) {
161+ h.replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: time.Unix(5, 6)}
162+}
163+
164+func (h *castHarness) open(id uint64, output string) (ports.CastEvent, uint64) {
165+ h.t.Helper()
166+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: output, Cursor: true}
167+ size := h.next(ports.CastSize)
168+ return size, castClientEvent[ports.CaptureSessionOpen](h).ID
169+}
170+
171+// state sends core's decision and waits until the display goroutine took it.
172+func (h *castHarness) state(core uint64, st ports.CaptureSessionState) {
173+ h.t.Helper()
174+ h.commands <- st
175+ for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
176+ var seen bool
177+ h.s.display.Do(func() { seen = h.s.castByCore[core].haveState })
178+ if seen {
179+ return
180+ }
181+ }
182+ h.t.Fatal("state not applied")
183+}
184+
185+func castFrame(id uint64, buf int, data []byte, w, hgt int) ports.CastCommand {
186+ return ports.CastCommand{Kind: ports.CastFrame, ID: id, Buf: buf, Data: data, Stride: w * 4, Width: w, Height: hgt, Format: 1}
187+}
188+
189+func (h *castHarness) flip(output string) {
190+ h.t.Helper()
191+ h.s.Presentation().Push(ports.OutputPresented{Output: output, Flip: &ports.FlipInfo{When: 3 * time.Second, Seq: 1}})
192+ h.sync()
193+}
194+
195+// An output cast: open registers it with core and reports its size; a frame is
196+// captured into the lent mapping, indicated once, and reported done.
197+func TestCastOpenSizeFrameDone(t *testing.T) {
198+ h := newCastHarness(t, true)
199+ size, core := h.open(7, "HEADLESS-1")
200+ if size.ID != 7 || size.Width != 64 || size.Height != 32 {
201+ t.Fatalf("size %+v", size)
202+ }
203+ if core == 0 {
204+ t.Fatal("cast not registered with core")
205+ }
206+ data := make([]byte, 64*32*4)
207+ h.casts <- castFrame(7, 3, data, 64, 32)
208+ req := h.request()
209+ if req.Output != "HEADLESS-1" || req.Session != core || !req.Indicate || !req.Cursor || req.Dst.File != nil ||
210+ len(req.Mapped) != len(data) || &req.Mapped[0] != &data[0] || req.Stride != 256 || req.Format != 1 || req.Region.Dx() != 64 || req.Region.Dy() != 32 {
211+ t.Fatalf("request %+v", req)
212+ }
213+ taken := castClientEvent[ports.CaptureFrameTaken](h)
214+ if taken.Session != core || taken.Output != "HEADLESS-1" {
215+ t.Fatalf("taken %+v", taken)
216+ }
217+ h.complete(req, nil)
218+ done := h.next(ports.CastFrameDone)
219+ if done.ID != 7 || done.Buf != 3 || done.Err != nil || !done.Time.Equal(time.Unix(5, 6)) {
220+ t.Fatalf("done %+v", done)
221+ }
222+ // Later frames are not indicated again: the cast keeps the indicator on.
223+ h.casts <- castFrame(7, 4, data, 64, 32)
224+ h.complete(h.request(), nil)
225+ if done = h.next(ports.CastFrameDone); done.Buf != 4 || done.Err != nil {
226+ t.Fatalf("second done %+v", done)
227+ }
228+ h.sync()
229+ for len(h.events) > 0 {
230+ if ev := <-h.events; ev != nil {
231+ if _, again := ev.(ports.CaptureFrameTaken); again {
232+ t.Fatalf("second frame indicated again: %+v", ev)
233+ }
234+ }
235+ }
236+ // CastClose ends the cast without a reply and tells core.
237+ h.casts <- ports.CastCommand{Kind: ports.CastClose, ID: 7}
238+ if c := castClientEvent[ports.CaptureSessionClose](h); c.ID != core {
239+ t.Fatalf("close %+v", c)
240+ }
241+ h.none()
242+ // A frame for a closed cast gives its buffer back.
243+ h.casts <- castFrame(7, 5, data, 64, 32)
244+ if done = h.next(ports.CastFrameDone); done.Buf != 5 || done.Err == nil {
245+ t.Fatalf("frame of a closed cast: %+v", done)
246+ }
247+}
248+
249+// A target that is missing refuses the cast.
250+func TestCastOpenRefused(t *testing.T) {
251+ h := newCastHarness(t, true)
252+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "NOPE-1"}
253+ if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Err == nil {
254+ t.Fatalf("stopped %+v", ev)
255+ }
256+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Window: 99}
257+ if ev := h.next(ports.CastStopped); ev.ID != 2 || ev.Reason != ports.CaptureReasonWindowGone {
258+ t.Fatalf("stopped %+v", ev)
259+ }
260+ for id := uint64(10); id < 10+maxCasts+1; id++ {
261+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: "HEADLESS-1"}
262+ }
263+ for range maxCasts {
264+ h.next(ports.CastSize)
265+ }
266+ if ev := h.next(ports.CastStopped); ev.ID != 10+maxCasts || !errors.Is(ev.Err, errCastLimit) {
267+ t.Fatalf("over the limit: %+v", ev)
268+ }
269+}
270+
271+// A window cast follows the window and stops when it closes.
272+func TestCastWindowCloseStops(t *testing.T) {
273+ h := newCastHarness(t, true)
274+ c := protocolClient(t, h.s, h.dir)
275+ w, _, xdg := surfaceMapper(t, c, h.events)()
276+ registerProtocol(t, c, xdg)
277+ top := xdg + 1
278+ placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
279+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 4, Window: w.ID}
280+ if size := h.next(ports.CastSize); size.ID != 4 || size.Width != 100 || size.Height != 100 {
281+ t.Fatalf("size %+v", size)
282+ }
283+ core := castClientEvent[ports.CaptureSessionOpen](h)
284+ if core.Window != w.ID {
285+ t.Fatalf("open %+v", core)
286+ }
287+ requestProtocol(t, c, top, xdgshell.ToplevelRequestDestroy)
288+ roundtrip(t, c)
289+ ev := h.next(ports.CastStopped)
290+ if ev.ID != 4 || ev.Reason != ports.CaptureReasonWindowGone {
291+ t.Fatalf("stopped %+v", ev)
292+ }
293+ if cl := castClientEvent[ports.CaptureSessionClose](h); cl.ID != core.ID {
294+ t.Fatalf("close %+v", cl)
295+ }
296+ h.none()
297+}
298+
299+// A session lock ends every cast and refuses new ones.
300+func TestCastSessionLockStops(t *testing.T) {
301+ h := newCastHarness(t, true)
302+ h.open(1, "HEADLESS-1")
303+ data := make([]byte, 64*32*4)
304+ h.casts <- castFrame(1, 0, data, 64, 32)
305+ req := h.request()
306+ c := protocolClient(t, h.s, h.dir)
307+ if _, err := bindLockManager(t, c).Lock(); err != nil {
308+ t.Fatal(err)
309+ }
310+ roundtrip(t, c)
311+ // The capture still writes into the lent buffer: its reply comes first.
312+ h.none()
313+ h.complete(req, nil)
314+ if done := h.next(ports.CastFrameDone); done.Buf != 0 || !errors.Is(done.Err, errCastStopped) {
315+ t.Fatalf("done %+v", done)
316+ }
317+ if ev := h.next(ports.CastStopped); ev.ID != 1 || !errors.Is(ev.Err, errCastProtected) {
318+ t.Fatalf("stopped %+v", ev)
319+ }
320+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Output: "HEADLESS-1"}
321+ if ev := h.next(ports.CastStopped); ev.ID != 2 || !errors.Is(ev.Err, errCastProtected) {
322+ t.Fatalf("open under lock: %+v", ev)
323+ }
324+}
325+
326+// An unplugged output ends the casts on it, after the capture in flight.
327+func TestCastOutputUnplugStops(t *testing.T) {
328+ h := newCastHarness(t, true)
329+ h.open(1, "HEADLESS-1")
330+ h.open(2, "HEADLESS-2")
331+ data := make([]byte, 64*32*4)
332+ h.casts <- castFrame(1, 9, data, 64, 32)
333+ req := h.request()
334+ h.commands <- ports.SetOutputs{Outputs: castLayout[1:], Focused: "HEADLESS-2"}
335+ h.none()
336+ h.complete(req, nil)
337+ if done := h.next(ports.CastFrameDone); done.ID != 1 || done.Buf != 9 || done.Err == nil {
338+ t.Fatalf("a frame written for a dead cast must not be queued: %+v", done)
339+ }
340+ if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Reason != ports.CaptureReasonOutputGone {
341+ t.Fatalf("stopped %+v", ev)
342+ }
343+ // The cast on the other output lives.
344+ h.casts <- castFrame(2, 1, make([]byte, 32*32*4), 32, 32)
345+ h.complete(h.request(), nil)
346+ if done := h.next(ports.CastFrameDone); done.ID != 2 || done.Err != nil {
347+ t.Fatalf("done %+v", done)
348+ }
349+}
350+
351+// A new mode renegotiates (CastSize); a frame of the old size is refused.
352+func TestCastSizeChange(t *testing.T) {
353+ h := newCastHarness(t, true)
354+ h.open(1, "HEADLESS-1")
355+ bigger := ports.Layout{castLayout[0], castLayout[1]}
356+ bigger[0].Info.Width, bigger[0].Info.Height, bigger[0].Width, bigger[0].Height = 128, 64, 128, 64
357+ h.commands <- ports.SetOutputs{Outputs: bigger, Focused: "HEADLESS-1"}
358+ if size := h.next(ports.CastSize); size.Width != 128 || size.Height != 64 {
359+ t.Fatalf("size %+v", size)
360+ }
361+ h.casts <- castFrame(1, 2, make([]byte, 64*32*4), 64, 32)
362+ if done := h.next(ports.CastFrameDone); done.Buf != 2 || !errors.Is(done.Err, errCastSize) {
363+ t.Fatalf("done %+v", done)
364+ }
365+ h.noRequest(20 * time.Millisecond)
366+ h.casts <- castFrame(1, 2, make([]byte, 128*64*4), 128, 64)
367+ h.complete(h.request(), nil)
368+ if done := h.next(ports.CastFrameDone); done.Err != nil {
369+ t.Fatalf("done %+v", done)
370+ }
371+}
372+
373+// A transient failure is retried on the cadence of an ext frame; past the
374+// deadline the frame fails with the error.
375+func TestCastTransientRetry(t *testing.T) {
376+ h := newCastHarness(t, true)
377+ h.open(1, "HEADLESS-1")
378+ data := make([]byte, 64*32*4)
379+ h.casts <- castFrame(1, 0, data, 64, 32)
380+ first := h.request()
381+ h.complete(first, ports.ErrCaptureBusy)
382+ start := time.Now()
383+ retried := h.request()
384+ if retried.ID == first.ID || time.Since(start) < captureRetryEvery/2 {
385+ t.Fatalf("retry %+v after %v", retried, time.Since(start))
386+ }
387+ h.complete(retried, nil)
388+ if done := h.next(ports.CastFrameDone); done.Err != nil {
389+ t.Fatalf("done %+v", done)
390+ }
391+
392+ // Never ready: retried until the deadline, then reported.
393+ h.casts <- castFrame(1, 1, data, 64, 32)
394+ attempts := 0
395+ for {
396+ select {
397+ case req := <-h.requests:
398+ attempts++
399+ h.complete(req, ports.ErrCaptureBusy)
400+ continue
401+ case ev := <-h.got:
402+ if ev.Kind != ports.CastFrameDone || ev.Buf != 1 || !errors.Is(ev.Err, ports.ErrCaptureTransient) {
403+ t.Fatalf("event %+v", ev)
404+ }
405+ case <-time.After(5 * time.Second):
406+ t.Fatal("frame neither retried nor failed")
407+ }
408+ break
409+ }
410+ if attempts < 10 {
411+ t.Fatalf("%d attempts in %v", attempts, captureRetryFor)
412+ }
413+}
414+
415+// Until core says the cast is active the frame waits, then goes out.
416+func TestCastWaitsForActiveState(t *testing.T) {
417+ h := newCastHarness(t, true)
418+ _, core := h.open(1, "HEADLESS-1")
419+ h.state(core, ports.CaptureSessionState{ID: core, Output: "HEADLESS-1"})
420+ h.casts <- castFrame(1, 0, make([]byte, 64*32*4), 64, 32)
421+ h.noRequest(60 * time.Millisecond)
422+ h.commands <- ports.CaptureSessionState{ID: core, Output: "HEADLESS-1", Active: true}
423+ h.complete(h.request(), nil)
424+ if done := h.next(ports.CastFrameDone); done.Err != nil {
425+ t.Fatalf("done %+v", done)
426+ }
427+ // A terminal decision of core ends the cast without telling core.
428+ h.commands <- ports.CaptureSessionState{ID: core, Reason: ports.CaptureReasonOutputGone}
429+ if ev := h.next(ports.CastStopped); ev.Reason != ports.CaptureReasonOutputGone {
430+ t.Fatalf("stopped %+v", ev)
431+ }
432+ h.sync()
433+ for len(h.events) > 0 {
434+ if _, closed := (<-h.events).(ports.CaptureSessionClose); closed {
435+ t.Fatal("core told of an end it decided")
436+ }
437+ }
438+}
439+
440+// A flip of the cast's output damages an output cast, and only that flip.
441+func TestCastFlipDamagesOutputCast(t *testing.T) {
442+ h := newCastHarness(t, true)
443+ h.open(1, "HEADLESS-1")
444+ h.open(2, "HEADLESS-2")
445+ h.flip("HEADLESS-1")
446+ ev := h.next(ports.CastDamage)
447+ if ev.ID != 1 || !ev.Time.Equal(time.Unix(3, 0)) {
448+ t.Fatalf("damage %+v", ev)
449+ }
450+ h.none()
451+ h.flip("HEADLESS-2")
452+ if ev = h.next(ports.CastDamage); ev.ID != 2 {
453+ t.Fatalf("damage %+v", ev)
454+ }
455+ // Every flip damages an output cast, committed or not.
456+ h.flip("HEADLESS-1")
457+ h.flip("HEADLESS-1")
458+ h.next(ports.CastDamage)
459+ h.next(ports.CastDamage)
460+ h.none()
461+}
462+
463+// A window cast is damaged by a flip of its output only after the window
464+// committed since the last damage.
465+func TestCastFlipDamagesWindowCastAfterCommit(t *testing.T) {
466+ h := newCastHarness(t, true)
467+ c := protocolClient(t, h.s, h.dir)
468+ w, _, _ := surfaceMapper(t, c, h.events)()
469+ placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
470+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID}
471+ h.next(ports.CastSize)
472+
473+ h.flip("HEADLESS-1")
474+ h.none() // nothing committed since the cast began
475+ commit := func() {
476+ if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID}, damage{full: true}) }) {
477+ t.Fatal("display stopped")
478+ }
479+ }
480+ commit()
481+ h.flip("HEADLESS-2")
482+ h.none() // not the window's output
483+ h.flip("HEADLESS-1")
484+ if ev := h.next(ports.CastDamage); ev.ID != 1 {
485+ t.Fatalf("damage %+v", ev)
486+ }
487+ h.flip("HEADLESS-1")
488+ h.none() // the damage consumed the commit
489+ commit()
490+ commit()
491+ h.flip("HEADLESS-1")
492+ h.next(ports.CastDamage)
493+ h.none() // two commits, one damage
494+}
495+
496+// The inventory is published once per change, not once per frame or flip, and
497+// changes of one display loop turn share one publication.
498+func TestCastInventoryPublishedOncePerChange(t *testing.T) {
499+ h := newCastHarness(t, true)
500+ inv := func() ports.CastInventory {
501+ t.Helper()
502+ select {
503+ case v := <-h.invs:
504+ return v
505+ case <-time.After(3 * time.Second):
506+ t.Fatal("no inventory")
507+ return ports.CastInventory{}
508+ }
509+ }
510+ noInv := func() {
511+ t.Helper()
512+ h.sync()
513+ h.sync()
514+ select {
515+ case v := <-h.invs:
516+ t.Fatalf("unexpected inventory %+v", v)
517+ default:
518+ }
519+ }
520+ first := inv()
521+ if len(first.Outputs) != 2 || len(first.Windows) != 0 || first.Outputs[0].Info.Name != "HEADLESS-1" || first.Outputs[0].Width != 64 {
522+ t.Fatalf("first inventory %+v", first)
523+ }
524+ for _, o := range first.Outputs {
525+ if o.Current < 0 || o.Current >= len(o.Modes) || o.Modes[o.Current].Width != o.Info.Width {
526+ t.Fatalf("current mode of %s: %+v", o.Info.Name, o)
527+ }
528+ }
529+ noInv()
530+
531+ c := protocolClient(t, h.s, h.dir)
532+ w, _, xdg := surfaceMapper(t, c, h.events)()
533+ registerProtocol(t, c, xdg)
534+ top := xdg + 1
535+ if got := inv(); len(got.Windows) != 1 || got.Windows[0].ID != w.ID {
536+ t.Fatalf("mapped inventory %+v", got)
537+ }
538+ noInv()
539+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
540+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetAppId, "app.editor")
541+ roundtrip(t, c)
542+ var got ports.CastInventory
543+ for got = inv(); got.Windows[0].Title != "editor" || got.Windows[0].AppID != "app.editor"; got = inv() {
544+ }
545+ noInv()
546+ // The same title again changes nothing.
547+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
548+ roundtrip(t, c)
549+ noInv()
550+
551+ // Frames and flips do not publish.
552+ h.open(1, "HEADLESS-1")
553+ data := make([]byte, 64*32*4)
554+ for i := range 20 {
555+ h.flip("HEADLESS-1")
556+ h.casts <- castFrame(1, i%3, data, 64, 32)
557+ h.complete(h.request(), nil)
558+ }
559+ noInv()
560+
561+ // Several changes in one turn: one publication, of the last state. An
562+ // unchanged inventory is not published at all.
563+ h.s.display.Do(func() { h.s.castInventoryChanged(); h.s.castInventoryChanged() })
564+ noInv()
565+ h.s.display.Do(func() {
566+ for _, title := range []string{"a", "b", "c"} {
567+ h.s.windows[w.ID].title = title
568+ h.s.castInventoryChanged()
569+ }
570+ })
571+ if got = inv(); len(got.Windows) != 1 || got.Windows[0].Title != "c" {
572+ t.Fatalf("inventory %+v", got)
573+ }
574+ noInv()
575+
576+ // An output moving is a change; the focused output too.
577+ moved := slices.Clone(castLayout)
578+ moved[1].X = 200
579+ h.commands <- ports.SetOutputs{Outputs: moved, Focused: "HEADLESS-2"}
580+ if got = inv(); got.Outputs[1].X != 200 || got.Focused != "HEADLESS-2" {
581+ t.Fatalf("inventory %+v", got)
582+ }
583+ noInv()
584+
585+ // Under a session lock the windows are not listed.
586+ lc := protocolClient(t, h.s, h.dir)
587+ if _, err := bindLockManager(t, lc).Lock(); err != nil {
588+ t.Fatal(err)
589+ }
590+ roundtrip(t, lc)
591+ for got = inv(); len(got.Windows) != 0; got = inv() {
592+ }
593+}
594+
595+// Pushing a cast frame, capturing it and answering the flip allocates nothing
596+// in steady state: the cast command rides the post ring, the request and the
597+// reply are plain values, the reply function is bound once. The count
598+// includes the display goroutine (AllocsPerRun reads the global malloc
599+// counter). Nothing in the closure is a testify mock; the sink is left out so
600+// the measured code is the server's.
601+func TestCastFrameAllocations(t *testing.T) {
602+ if raceEnabled {
603+ t.Skip("the race detector makes the purego wake callback allocate")
604+ }
605+ h := newCastHarness(t, false)
606+ s := h.s
607+ s.castRing.Push(ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "HEADLESS-1"})
608+ core := castClientEvent[ports.CaptureSessionOpen](h).ID
609+ data := make([]byte, 64*32*4)
610+ frame := castFrame(1, 0, data, 64, 32)
611+ flip := ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: time.Second, Seq: 1}}
612+ ran := make(chan struct{}, 1)
613+ marker := func() { ran <- struct{}{} }
614+ at := time.Unix(1, 2)
615+ cycle := func() {
616+ s.castRing.Push(frame)
617+ req := <-h.requests
618+ s.capturedRing.Push(ports.CaptureDone{ID: req.ID, Output: req.Output, Time: at})
619+ s.Presentation().Push(flip)
620+ s.display.Post(marker)
621+ <-ran
622+ }
623+ for range 4 {
624+ cycle()
625+ }
626+ if taken := castClientEvent[ports.CaptureFrameTaken](h); taken.Session != core {
627+ t.Fatalf("taken %+v", taken)
628+ }
629+ var live int
630+ s.display.Do(func() { live = len(s.castSessions) + len(s.captureInflight) + len(s.captureReplies) })
631+ if live != 1 {
632+ t.Fatalf("cast %d, inflight and replies must be empty after a cycle", live)
633+ }
634+ if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 {
635+ t.Errorf("cast frame cycle: %.1f allocs, want 0", allocs)
636+ }
637+}
@@ -0,0 +1,637 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "slices"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
11+ "github.com/bnema/neferwl/internal/adapters/logging"
12+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
13+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/stretchr/testify/mock"
16+)
17+
18+// castLayout: two small outputs, so frames are cheap and a flip of the other
19+// output can be told from a flip of the cast's.
20+var castLayout = ports.Layout{
21+ {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32}, Width: 64, Height: 32, Scale: 1},
22+ {Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 32, Height: 32}, X: 64, Width: 32, Height: 32, Scale: 1},
23+}
24+
25+// castHarness runs a server with the cast channels wired: the commands go in
26+// through casts, the events and inventories the server publishes are recorded
27+// by the generated CastSink mock into got and invs, and the capture requests
28+// and replies are the test's.
29+type castHarness struct {
30+ t *testing.T
31+ s *Server
32+ dir string
33+ events chan ports.ClientEvent
34+ commands chan ports.ClientCommand
35+ requests chan ports.CaptureRequest
36+ replies chan ports.CaptureDone
37+ casts chan ports.CastCommand
38+ got chan ports.CastEvent
39+ invs chan ports.CastInventory
40+ changes chan ports.SecurityState
41+}
42+
43+// newCastHarness starts the server; withSink false leaves Options.Casts nil
44+// (the allocation test measures the server alone).
45+func newCastHarness(t *testing.T, withSink bool) *castHarness {
46+ t.Helper()
47+ h := &castHarness{
48+ t: t,
49+ dir: t.TempDir(),
50+ events: make(chan ports.ClientEvent, 256),
51+ commands: make(chan ports.ClientCommand, 16),
52+ requests: make(chan ports.CaptureRequest, maxCaptureInflight),
53+ replies: make(chan ports.CaptureDone, maxCaptureInflight+2),
54+ casts: make(chan ports.CastCommand, 16),
55+ got: make(chan ports.CastEvent, 4096),
56+ invs: make(chan ports.CastInventory, 64),
57+ changes: make(chan ports.SecurityState, 16),
58+ }
59+ opts := Options{RuntimeDir: h.dir, Outputs: castLayout, Security: &sessionsecurity.Gate{}}
60+ if withSink {
61+ sink := portsmocks.NewMockCastSink(t)
62+ sink.EXPECT().Push(mock.Anything).RunAndReturn(func(ev ports.CastEvent) { h.got <- ev }).Maybe()
63+ sink.EXPECT().Inventory(mock.Anything).RunAndReturn(func(inv ports.CastInventory) { h.invs <- inv }).Maybe()
64+ opts.Casts = sink
65+ }
66+ ch := Channels{
67+ Events: h.events, Commands: h.commands, Captures: h.requests, Captured: h.replies, CastCommands: h.casts,
68+ SecurityChanges: h.changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16),
69+ }
70+ s, err := New(opts, ch, logging.For(context.Background(), "wayland"))
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ h.s = s
75+ relayContents(t, s)
76+ ctx, cancel := context.WithCancel(context.Background())
77+ done := make(chan error, 1)
78+ go func() { done <- s.Run(ctx) }()
79+ t.Cleanup(func() { cancel(); <-done })
80+ return h
81+}
82+
83+// sync returns once the display goroutine ran everything posted before.
84+func (h *castHarness) sync() {
85+ h.t.Helper()
86+ ran := make(chan struct{})
87+ h.s.display.Post(func() { close(ran) })
88+ select {
89+ case <-ran:
90+ case <-time.After(3 * time.Second):
91+ h.t.Fatal("display goroutine stalled")
92+ }
93+}
94+
95+// next is the next event pushed to the sink, which must have that kind.
96+func (h *castHarness) next(kind ports.CastEventKind) ports.CastEvent {
97+ h.t.Helper()
98+ select {
99+ case ev := <-h.got:
100+ if ev.Kind != kind {
101+ h.t.Fatalf("event %+v, want kind %d", ev, kind)
102+ }
103+ return ev
104+ case <-time.After(3 * time.Second):
105+ h.t.Fatalf("no cast event of kind %d", kind)
106+ return ports.CastEvent{}
107+ }
108+}
109+
110+// none says that no event is pending once the display goroutine caught up.
111+func (h *castHarness) none() {
112+ h.t.Helper()
113+ h.sync()
114+ select {
115+ case ev := <-h.got:
116+ h.t.Fatalf("unexpected cast event %+v", ev)
117+ default:
118+ }
119+}
120+
121+func (h *castHarness) request() ports.CaptureRequest {
122+ h.t.Helper()
123+ select {
124+ case r := <-h.requests:
125+ return r
126+ case <-time.After(3 * time.Second):
127+ h.t.Fatal("no capture request")
128+ return ports.CaptureRequest{}
129+ }
130+}
131+
132+// noRequest says that no capture was requested.
133+func (h *castHarness) noRequest(wait time.Duration) {
134+ h.t.Helper()
135+ select {
136+ case r := <-h.requests:
137+ h.t.Fatalf("unexpected capture request for cast session %d on %s", r.Session, r.Output)
138+ case <-time.After(wait):
139+ }
140+}
141+
142+// castClientEvent waits for the next ClientEvent of type T, skipping others.
143+func castClientEvent[T ports.ClientEvent](h *castHarness) T {
144+ h.t.Helper()
145+ for {
146+ select {
147+ case ev := <-h.events:
148+ if v, ok := ev.(T); ok {
149+ return v
150+ }
151+ case <-time.After(3 * time.Second):
152+ var zero T
153+ h.t.Fatalf("no %T", zero)
154+ return zero
155+ }
156+ }
157+}
158+
159+// complete answers a capture request the way an output does.
160+func (h *castHarness) complete(req ports.CaptureRequest, err error) {
161+ h.replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: time.Unix(5, 6)}
162+}
163+
164+func (h *castHarness) open(id uint64, output string) (ports.CastEvent, uint64) {
165+ h.t.Helper()
166+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: output, Cursor: true}
167+ size := h.next(ports.CastSize)
168+ return size, castClientEvent[ports.CaptureSessionOpen](h).ID
169+}
170+
171+// state sends core's decision and waits until the display goroutine took it.
172+func (h *castHarness) state(core uint64, st ports.CaptureSessionState) {
173+ h.t.Helper()
174+ h.commands <- st
175+ for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
176+ var seen bool
177+ h.s.display.Do(func() { seen = h.s.castByCore[core].haveState })
178+ if seen {
179+ return
180+ }
181+ }
182+ h.t.Fatal("state not applied")
183+}
184+
185+func castFrame(id uint64, buf int, data []byte, w, hgt int) ports.CastCommand {
186+ return ports.CastCommand{Kind: ports.CastFrame, ID: id, Buf: buf, Data: data, Stride: w * 4, Width: w, Height: hgt, Format: 1}
187+}
188+
189+func (h *castHarness) flip(output string) {
190+ h.t.Helper()
191+ h.s.Presentation().Push(ports.OutputPresented{Output: output, Flip: &ports.FlipInfo{When: 3 * time.Second, Seq: 1}})
192+ h.sync()
193+}
194+
195+// An output cast: open registers it with core and reports its size; a frame is
196+// captured into the lent mapping, indicated once, and reported done.
197+func TestCastOpenSizeFrameDone(t *testing.T) {
198+ h := newCastHarness(t, true)
199+ size, core := h.open(7, "HEADLESS-1")
200+ if size.ID != 7 || size.Width != 64 || size.Height != 32 {
201+ t.Fatalf("size %+v", size)
202+ }
203+ if core == 0 {
204+ t.Fatal("cast not registered with core")
205+ }
206+ data := make([]byte, 64*32*4)
207+ h.casts <- castFrame(7, 3, data, 64, 32)
208+ req := h.request()
209+ if req.Output != "HEADLESS-1" || req.Session != core || !req.Indicate || !req.Cursor || req.Dst.File != nil ||
210+ len(req.Mapped) != len(data) || &req.Mapped[0] != &data[0] || req.Stride != 256 || req.Format != 1 || req.Region.Dx() != 64 || req.Region.Dy() != 32 {
211+ t.Fatalf("request %+v", req)
212+ }
213+ taken := castClientEvent[ports.CaptureFrameTaken](h)
214+ if taken.Session != core || taken.Output != "HEADLESS-1" {
215+ t.Fatalf("taken %+v", taken)
216+ }
217+ h.complete(req, nil)
218+ done := h.next(ports.CastFrameDone)
219+ if done.ID != 7 || done.Buf != 3 || done.Err != nil || !done.Time.Equal(time.Unix(5, 6)) {
220+ t.Fatalf("done %+v", done)
221+ }
222+ // Later frames are not indicated again: the cast keeps the indicator on.
223+ h.casts <- castFrame(7, 4, data, 64, 32)
224+ h.complete(h.request(), nil)
225+ if done = h.next(ports.CastFrameDone); done.Buf != 4 || done.Err != nil {
226+ t.Fatalf("second done %+v", done)
227+ }
228+ h.sync()
229+ for len(h.events) > 0 {
230+ if ev := <-h.events; ev != nil {
231+ if _, again := ev.(ports.CaptureFrameTaken); again {
232+ t.Fatalf("second frame indicated again: %+v", ev)
233+ }
234+ }
235+ }
236+ // CastClose ends the cast without a reply and tells core.
237+ h.casts <- ports.CastCommand{Kind: ports.CastClose, ID: 7}
238+ if c := castClientEvent[ports.CaptureSessionClose](h); c.ID != core {
239+ t.Fatalf("close %+v", c)
240+ }
241+ h.none()
242+ // A frame for a closed cast gives its buffer back.
243+ h.casts <- castFrame(7, 5, data, 64, 32)
244+ if done = h.next(ports.CastFrameDone); done.Buf != 5 || done.Err == nil {
245+ t.Fatalf("frame of a closed cast: %+v", done)
246+ }
247+}
248+
249+// A target that is missing refuses the cast.
250+func TestCastOpenRefused(t *testing.T) {
251+ h := newCastHarness(t, true)
252+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "NOPE-1"}
253+ if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Err == nil {
254+ t.Fatalf("stopped %+v", ev)
255+ }
256+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Window: 99}
257+ if ev := h.next(ports.CastStopped); ev.ID != 2 || ev.Reason != ports.CaptureReasonWindowGone {
258+ t.Fatalf("stopped %+v", ev)
259+ }
260+ for id := uint64(10); id < 10+maxCasts+1; id++ {
261+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: "HEADLESS-1"}
262+ }
263+ for range maxCasts {
264+ h.next(ports.CastSize)
265+ }
266+ if ev := h.next(ports.CastStopped); ev.ID != 10+maxCasts || !errors.Is(ev.Err, errCastLimit) {
267+ t.Fatalf("over the limit: %+v", ev)
268+ }
269+}
270+
271+// A window cast follows the window and stops when it closes.
272+func TestCastWindowCloseStops(t *testing.T) {
273+ h := newCastHarness(t, true)
274+ c := protocolClient(t, h.s, h.dir)
275+ w, _, xdg := surfaceMapper(t, c, h.events)()
276+ registerProtocol(t, c, xdg)
277+ top := xdg + 1
278+ placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
279+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 4, Window: w.ID}
280+ if size := h.next(ports.CastSize); size.ID != 4 || size.Width != 100 || size.Height != 100 {
281+ t.Fatalf("size %+v", size)
282+ }
283+ core := castClientEvent[ports.CaptureSessionOpen](h)
284+ if core.Window != w.ID {
285+ t.Fatalf("open %+v", core)
286+ }
287+ requestProtocol(t, c, top, xdgshell.ToplevelRequestDestroy)
288+ roundtrip(t, c)
289+ ev := h.next(ports.CastStopped)
290+ if ev.ID != 4 || ev.Reason != ports.CaptureReasonWindowGone {
291+ t.Fatalf("stopped %+v", ev)
292+ }
293+ if cl := castClientEvent[ports.CaptureSessionClose](h); cl.ID != core.ID {
294+ t.Fatalf("close %+v", cl)
295+ }
296+ h.none()
297+}
298+
299+// A session lock ends every cast and refuses new ones.
300+func TestCastSessionLockStops(t *testing.T) {
301+ h := newCastHarness(t, true)
302+ h.open(1, "HEADLESS-1")
303+ data := make([]byte, 64*32*4)
304+ h.casts <- castFrame(1, 0, data, 64, 32)
305+ req := h.request()
306+ c := protocolClient(t, h.s, h.dir)
307+ if _, err := bindLockManager(t, c).Lock(); err != nil {
308+ t.Fatal(err)
309+ }
310+ roundtrip(t, c)
311+ // The capture still writes into the lent buffer: its reply comes first.
312+ h.none()
313+ h.complete(req, nil)
314+ if done := h.next(ports.CastFrameDone); done.Buf != 0 || !errors.Is(done.Err, errCastStopped) {
315+ t.Fatalf("done %+v", done)
316+ }
317+ if ev := h.next(ports.CastStopped); ev.ID != 1 || !errors.Is(ev.Err, errCastProtected) {
318+ t.Fatalf("stopped %+v", ev)
319+ }
320+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Output: "HEADLESS-1"}
321+ if ev := h.next(ports.CastStopped); ev.ID != 2 || !errors.Is(ev.Err, errCastProtected) {
322+ t.Fatalf("open under lock: %+v", ev)
323+ }
324+}
325+
326+// An unplugged output ends the casts on it, after the capture in flight.
327+func TestCastOutputUnplugStops(t *testing.T) {
328+ h := newCastHarness(t, true)
329+ h.open(1, "HEADLESS-1")
330+ h.open(2, "HEADLESS-2")
331+ data := make([]byte, 64*32*4)
332+ h.casts <- castFrame(1, 9, data, 64, 32)
333+ req := h.request()
334+ h.commands <- ports.SetOutputs{Outputs: castLayout[1:], Focused: "HEADLESS-2"}
335+ h.none()
336+ h.complete(req, nil)
337+ if done := h.next(ports.CastFrameDone); done.ID != 1 || done.Buf != 9 || done.Err == nil {
338+ t.Fatalf("a frame written for a dead cast must not be queued: %+v", done)
339+ }
340+ if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Reason != ports.CaptureReasonOutputGone {
341+ t.Fatalf("stopped %+v", ev)
342+ }
343+ // The cast on the other output lives.
344+ h.casts <- castFrame(2, 1, make([]byte, 32*32*4), 32, 32)
345+ h.complete(h.request(), nil)
346+ if done := h.next(ports.CastFrameDone); done.ID != 2 || done.Err != nil {
347+ t.Fatalf("done %+v", done)
348+ }
349+}
350+
351+// A new mode renegotiates (CastSize); a frame of the old size is refused.
352+func TestCastSizeChange(t *testing.T) {
353+ h := newCastHarness(t, true)
354+ h.open(1, "HEADLESS-1")
355+ bigger := ports.Layout{castLayout[0], castLayout[1]}
356+ bigger[0].Info.Width, bigger[0].Info.Height, bigger[0].Width, bigger[0].Height = 128, 64, 128, 64
357+ h.commands <- ports.SetOutputs{Outputs: bigger, Focused: "HEADLESS-1"}
358+ if size := h.next(ports.CastSize); size.Width != 128 || size.Height != 64 {
359+ t.Fatalf("size %+v", size)
360+ }
361+ h.casts <- castFrame(1, 2, make([]byte, 64*32*4), 64, 32)
362+ if done := h.next(ports.CastFrameDone); done.Buf != 2 || !errors.Is(done.Err, errCastSize) {
363+ t.Fatalf("done %+v", done)
364+ }
365+ h.noRequest(20 * time.Millisecond)
366+ h.casts <- castFrame(1, 2, make([]byte, 128*64*4), 128, 64)
367+ h.complete(h.request(), nil)
368+ if done := h.next(ports.CastFrameDone); done.Err != nil {
369+ t.Fatalf("done %+v", done)
370+ }
371+}
372+
373+// A transient failure is retried on the cadence of an ext frame; past the
374+// deadline the frame fails with the error.
375+func TestCastTransientRetry(t *testing.T) {
376+ h := newCastHarness(t, true)
377+ h.open(1, "HEADLESS-1")
378+ data := make([]byte, 64*32*4)
379+ h.casts <- castFrame(1, 0, data, 64, 32)
380+ first := h.request()
381+ h.complete(first, ports.ErrCaptureBusy)
382+ start := time.Now()
383+ retried := h.request()
384+ if retried.ID == first.ID || time.Since(start) < captureRetryEvery/2 {
385+ t.Fatalf("retry %+v after %v", retried, time.Since(start))
386+ }
387+ h.complete(retried, nil)
388+ if done := h.next(ports.CastFrameDone); done.Err != nil {
389+ t.Fatalf("done %+v", done)
390+ }
391+
392+ // Never ready: retried until the deadline, then reported.
393+ h.casts <- castFrame(1, 1, data, 64, 32)
394+ attempts := 0
395+ for {
396+ select {
397+ case req := <-h.requests:
398+ attempts++
399+ h.complete(req, ports.ErrCaptureBusy)
400+ continue
401+ case ev := <-h.got:
402+ if ev.Kind != ports.CastFrameDone || ev.Buf != 1 || !errors.Is(ev.Err, ports.ErrCaptureTransient) {
403+ t.Fatalf("event %+v", ev)
404+ }
405+ case <-time.After(5 * time.Second):
406+ t.Fatal("frame neither retried nor failed")
407+ }
408+ break
409+ }
410+ if attempts < 10 {
411+ t.Fatalf("%d attempts in %v", attempts, captureRetryFor)
412+ }
413+}
414+
415+// Until core says the cast is active the frame waits, then goes out.
416+func TestCastWaitsForActiveState(t *testing.T) {
417+ h := newCastHarness(t, true)
418+ _, core := h.open(1, "HEADLESS-1")
419+ h.state(core, ports.CaptureSessionState{ID: core, Output: "HEADLESS-1"})
420+ h.casts <- castFrame(1, 0, make([]byte, 64*32*4), 64, 32)
421+ h.noRequest(60 * time.Millisecond)
422+ h.commands <- ports.CaptureSessionState{ID: core, Output: "HEADLESS-1", Active: true}
423+ h.complete(h.request(), nil)
424+ if done := h.next(ports.CastFrameDone); done.Err != nil {
425+ t.Fatalf("done %+v", done)
426+ }
427+ // A terminal decision of core ends the cast without telling core.
428+ h.commands <- ports.CaptureSessionState{ID: core, Reason: ports.CaptureReasonOutputGone}
429+ if ev := h.next(ports.CastStopped); ev.Reason != ports.CaptureReasonOutputGone {
430+ t.Fatalf("stopped %+v", ev)
431+ }
432+ h.sync()
433+ for len(h.events) > 0 {
434+ if _, closed := (<-h.events).(ports.CaptureSessionClose); closed {
435+ t.Fatal("core told of an end it decided")
436+ }
437+ }
438+}
439+
440+// A flip of the cast's output damages an output cast, and only that flip.
441+func TestCastFlipDamagesOutputCast(t *testing.T) {
442+ h := newCastHarness(t, true)
443+ h.open(1, "HEADLESS-1")
444+ h.open(2, "HEADLESS-2")
445+ h.flip("HEADLESS-1")
446+ ev := h.next(ports.CastDamage)
447+ if ev.ID != 1 || !ev.Time.Equal(time.Unix(3, 0)) {
448+ t.Fatalf("damage %+v", ev)
449+ }
450+ h.none()
451+ h.flip("HEADLESS-2")
452+ if ev = h.next(ports.CastDamage); ev.ID != 2 {
453+ t.Fatalf("damage %+v", ev)
454+ }
455+ // Every flip damages an output cast, committed or not.
456+ h.flip("HEADLESS-1")
457+ h.flip("HEADLESS-1")
458+ h.next(ports.CastDamage)
459+ h.next(ports.CastDamage)
460+ h.none()
461+}
462+
463+// A window cast is damaged by a flip of its output only after the window
464+// committed since the last damage.
465+func TestCastFlipDamagesWindowCastAfterCommit(t *testing.T) {
466+ h := newCastHarness(t, true)
467+ c := protocolClient(t, h.s, h.dir)
468+ w, _, _ := surfaceMapper(t, c, h.events)()
469+ placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
470+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID}
471+ h.next(ports.CastSize)
472+
473+ h.flip("HEADLESS-1")
474+ h.none() // nothing committed since the cast began
475+ commit := func() {
476+ if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID}, damage{full: true}) }) {
477+ t.Fatal("display stopped")
478+ }
479+ }
480+ commit()
481+ h.flip("HEADLESS-2")
482+ h.none() // not the window's output
483+ h.flip("HEADLESS-1")
484+ if ev := h.next(ports.CastDamage); ev.ID != 1 {
485+ t.Fatalf("damage %+v", ev)
486+ }
487+ h.flip("HEADLESS-1")
488+ h.none() // the damage consumed the commit
489+ commit()
490+ commit()
491+ h.flip("HEADLESS-1")
492+ h.next(ports.CastDamage)
493+ h.none() // two commits, one damage
494+}
495+
496+// The inventory is published once per change, not once per frame or flip, and
497+// changes of one display loop turn share one publication.
498+func TestCastInventoryPublishedOncePerChange(t *testing.T) {
499+ h := newCastHarness(t, true)
500+ inv := func() ports.CastInventory {
501+ t.Helper()
502+ select {
503+ case v := <-h.invs:
504+ return v
505+ case <-time.After(3 * time.Second):
506+ t.Fatal("no inventory")
507+ return ports.CastInventory{}
508+ }
509+ }
510+ noInv := func() {
511+ t.Helper()
512+ h.sync()
513+ h.sync()
514+ select {
515+ case v := <-h.invs:
516+ t.Fatalf("unexpected inventory %+v", v)
517+ default:
518+ }
519+ }
520+ first := inv()
521+ if len(first.Outputs) != 2 || len(first.Windows) != 0 || first.Outputs[0].Info.Name != "HEADLESS-1" || first.Outputs[0].Width != 64 {
522+ t.Fatalf("first inventory %+v", first)
523+ }
524+ for _, o := range first.Outputs {
525+ if o.Current < 0 || o.Current >= len(o.Modes) || o.Modes[o.Current].Width != o.Info.Width {
526+ t.Fatalf("current mode of %s: %+v", o.Info.Name, o)
527+ }
528+ }
529+ noInv()
530+
531+ c := protocolClient(t, h.s, h.dir)
532+ w, _, xdg := surfaceMapper(t, c, h.events)()
533+ registerProtocol(t, c, xdg)
534+ top := xdg + 1
535+ if got := inv(); len(got.Windows) != 1 || got.Windows[0].ID != w.ID {
536+ t.Fatalf("mapped inventory %+v", got)
537+ }
538+ noInv()
539+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
540+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetAppId, "app.editor")
541+ roundtrip(t, c)
542+ var got ports.CastInventory
543+ for got = inv(); got.Windows[0].Title != "editor" || got.Windows[0].AppID != "app.editor"; got = inv() {
544+ }
545+ noInv()
546+ // The same title again changes nothing.
547+ requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
548+ roundtrip(t, c)
549+ noInv()
550+
551+ // Frames and flips do not publish.
552+ h.open(1, "HEADLESS-1")
553+ data := make([]byte, 64*32*4)
554+ for i := range 20 {
555+ h.flip("HEADLESS-1")
556+ h.casts <- castFrame(1, i%3, data, 64, 32)
557+ h.complete(h.request(), nil)
558+ }
559+ noInv()
560+
561+ // Several changes in one turn: one publication, of the last state. An
562+ // unchanged inventory is not published at all.
563+ h.s.display.Do(func() { h.s.castInventoryChanged(); h.s.castInventoryChanged() })
564+ noInv()
565+ h.s.display.Do(func() {
566+ for _, title := range []string{"a", "b", "c"} {
567+ h.s.windows[w.ID].title = title
568+ h.s.castInventoryChanged()
569+ }
570+ })
571+ if got = inv(); len(got.Windows) != 1 || got.Windows[0].Title != "c" {
572+ t.Fatalf("inventory %+v", got)
573+ }
574+ noInv()
575+
576+ // An output moving is a change; the focused output too.
577+ moved := slices.Clone(castLayout)
578+ moved[1].X = 200
579+ h.commands <- ports.SetOutputs{Outputs: moved, Focused: "HEADLESS-2"}
580+ if got = inv(); got.Outputs[1].X != 200 || got.Focused != "HEADLESS-2" {
581+ t.Fatalf("inventory %+v", got)
582+ }
583+ noInv()
584+
585+ // Under a session lock the windows are not listed.
586+ lc := protocolClient(t, h.s, h.dir)
587+ if _, err := bindLockManager(t, lc).Lock(); err != nil {
588+ t.Fatal(err)
589+ }
590+ roundtrip(t, lc)
591+ for got = inv(); len(got.Windows) != 0; got = inv() {
592+ }
593+}
594+
595+// Pushing a cast frame, capturing it and answering the flip allocates nothing
596+// in steady state: the cast command rides the post ring, the request and the
597+// reply are plain values, the reply function is bound once. The count
598+// includes the display goroutine (AllocsPerRun reads the global malloc
599+// counter). Nothing in the closure is a testify mock; the sink is left out so
600+// the measured code is the server's.
601+func TestCastFrameAllocations(t *testing.T) {
602+ if raceEnabled {
603+ t.Skip("the race detector makes the purego wake callback allocate")
604+ }
605+ h := newCastHarness(t, false)
606+ s := h.s
607+ s.castRing.Push(ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "HEADLESS-1"})
608+ core := castClientEvent[ports.CaptureSessionOpen](h).ID
609+ data := make([]byte, 64*32*4)
610+ frame := castFrame(1, 0, data, 64, 32)
611+ flip := ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: time.Second, Seq: 1}}
612+ ran := make(chan struct{}, 1)
613+ marker := func() { ran <- struct{}{} }
614+ at := time.Unix(1, 2)
615+ cycle := func() {
616+ s.castRing.Push(frame)
617+ req := <-h.requests
618+ s.capturedRing.Push(ports.CaptureDone{ID: req.ID, Output: req.Output, Time: at})
619+ s.Presentation().Push(flip)
620+ s.display.Post(marker)
621+ <-ran
622+ }
623+ for range 4 {
624+ cycle()
625+ }
626+ if taken := castClientEvent[ports.CaptureFrameTaken](h); taken.Session != core {
627+ t.Fatalf("taken %+v", taken)
628+ }
629+ var live int
630+ s.display.Do(func() { live = len(s.castSessions) + len(s.captureInflight) + len(s.captureReplies) })
631+ if live != 1 {
632+ t.Fatalf("cast %d, inflight and replies must be empty after a cycle", live)
633+ }
634+ if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 {
635+ t.Errorf("cast frame cycle: %.1f allocs, want 0", allocs)
636+ }
637+}
M internal/adapters/wayland/server.go +30 -3
@@ -48,6 +48,9 @@ type Options struct {
4848 // nil lets every client capture, unless the session is protected. The
4949 // caller keeps it current (captureallow.Store.Run).
5050 CaptureAllow *captureallow.Store
51+ // Casts receives the screencast events and inventory (screencast.go);
52+ // nil serves no casts (Channels.CastCommands is not read).
53+ Casts ports.CastSink
5154 // LogDamage logs the damage of every fresh client commit at debug level
5255 // (--debug=wayland-damage).
5356 LogDamage bool
@@ -78,6 +81,9 @@ type Channels struct {
7881 // never block the backend; standalone tests may use smaller channels.
7982 Captures chan<- ports.CaptureRequest
8083 Captured <-chan ports.CaptureDone
84+ // CastCommands carries the screencast adapter's commands (ports.CastCommand);
85+ // they are applied in order on the display goroutine. Nil serves no casts.
86+ CastCommands <-chan ports.CastCommand
8187 // OutputFormats are the outputs' direct scanout formats, offered in
8288 // dmabuf feedback to fullscreen surfaces.
8389 OutputFormats <-chan ports.OutputFormats
@@ -122,9 +128,20 @@ type Server struct {
122128 // sessionsByID are the sessions registered with core (imagecopy.go);
123129 // excl is the one live exclusion (exclusion.go); ownUID the compositor's.
124130 sessionsByID map[uint64]*captureSession
125- nextSession uint64
126- excl *exclusion
127- ownUID uint32
131+ // Screencast (screencast.go): casts by the adapter's ID and by the ID
132+ // core knows them by; castRing carries the adapter's commands to the
133+ // display goroutine; the castInv fields coalesce inventory publication.
134+ casts ports.CastSink
135+ castSessions map[uint64]*castSession
136+ castByCore map[uint64]*castSession
137+ castRing *postRing[ports.CastCommand]
138+ castInvFn func()
139+ castInvPosted bool
140+ castInvSent bool
141+ castInvLast ports.CastInventory
142+ nextSession uint64
143+ excl *exclusion
144+ ownUID uint32
128145 // captureAllow, peer and peers decide mayCapture (captureauth.go):
129146 // peers keeps each client's pidfd until it is destroyed.
130147 captureAllow *captureallow.Store
@@ -362,6 +379,10 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
362379 s.flushLinksFn = s.flushLinks
363380 s.sink = newPresentSink(s)
364381 s.capturedRing = newPostRing(d, capturedRingSize, s.applyCaptured)
382+ s.casts = opts.Casts
383+ s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{}
384+ s.castRing = newPostRing(d, castRingSize, s.applyCast)
385+ s.castInvFn = s.publishCastInventory
365386 s.leaseDevices = map[string]*leaseDevice{}
366387 s.pendingLeases = map[uint64]*leaseObject{}
367388 s.pendingLeaseCards = map[uint64]string{}
@@ -553,6 +574,11 @@ func (s *Server) Run(ctx context.Context) error {
553574 wg.Add(1)
554575 go func() { defer wg.Done(); s.syncWait.run(ctx) }()
555576 }
577+ if s.casts != nil && s.channels.CastCommands != nil {
578+ wg.Add(1)
579+ go func() { defer wg.Done(); s.forwardCasts(ctx.Done()) }()
580+ }
581+ s.castInventoryChanged()
556582 err := s.display.Run(ctx)
557583 s.stopSecurityContexts()
558584 wg.Wait()
@@ -710,6 +736,7 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) {
710736 l.add(c)
711737 s.flushLink(l)
712738 }
739+ s.castCommitted(c.ID)
713740 }
714741
715742 // maxCommandBatch bounds how many queued commands one display round trip applies.
@@ -48,6 +48,9 @@ type Options struct {
48 // nil lets every client capture, unless the session is protected. The48 // nil lets every client capture, unless the session is protected. The
49 // caller keeps it current (captureallow.Store.Run).49 // caller keeps it current (captureallow.Store.Run).
50 CaptureAllow *captureallow.Store50 CaptureAllow *captureallow.Store
51+ // Casts receives the screencast events and inventory (screencast.go);
52+ // nil serves no casts (Channels.CastCommands is not read).
53+ Casts ports.CastSink
51 // LogDamage logs the damage of every fresh client commit at debug level54 // LogDamage logs the damage of every fresh client commit at debug level
52 // (--debug=wayland-damage).55 // (--debug=wayland-damage).
53 LogDamage bool56 LogDamage bool
@@ -78,6 +81,9 @@ type Channels struct {
78 // never block the backend; standalone tests may use smaller channels.81 // never block the backend; standalone tests may use smaller channels.
79 Captures chan<- ports.CaptureRequest82 Captures chan<- ports.CaptureRequest
80 Captured <-chan ports.CaptureDone83 Captured <-chan ports.CaptureDone
84+ // CastCommands carries the screencast adapter's commands (ports.CastCommand);
85+ // they are applied in order on the display goroutine. Nil serves no casts.
86+ CastCommands <-chan ports.CastCommand
81 // OutputFormats are the outputs' direct scanout formats, offered in87 // OutputFormats are the outputs' direct scanout formats, offered in
82 // dmabuf feedback to fullscreen surfaces.88 // dmabuf feedback to fullscreen surfaces.
83 OutputFormats <-chan ports.OutputFormats89 OutputFormats <-chan ports.OutputFormats
@@ -122,9 +128,20 @@ type Server struct {
122 // sessionsByID are the sessions registered with core (imagecopy.go);128 // sessionsByID are the sessions registered with core (imagecopy.go);
123 // excl is the one live exclusion (exclusion.go); ownUID the compositor's.129 // excl is the one live exclusion (exclusion.go); ownUID the compositor's.
124 sessionsByID map[uint64]*captureSession130 sessionsByID map[uint64]*captureSession
125- nextSession uint64131+ // Screencast (screencast.go): casts by the adapter's ID and by the ID
126- excl *exclusion132+ // core knows them by; castRing carries the adapter's commands to the
127- ownUID uint32133+ // display goroutine; the castInv fields coalesce inventory publication.
134+ casts ports.CastSink
135+ castSessions map[uint64]*castSession
136+ castByCore map[uint64]*castSession
137+ castRing *postRing[ports.CastCommand]
138+ castInvFn func()
139+ castInvPosted bool
140+ castInvSent bool
141+ castInvLast ports.CastInventory
142+ nextSession uint64
143+ excl *exclusion
144+ ownUID uint32
128 // captureAllow, peer and peers decide mayCapture (captureauth.go):145 // captureAllow, peer and peers decide mayCapture (captureauth.go):
129 // peers keeps each client's pidfd until it is destroyed.146 // peers keeps each client's pidfd until it is destroyed.
130 captureAllow *captureallow.Store147 captureAllow *captureallow.Store
@@ -362,6 +379,10 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
362 s.flushLinksFn = s.flushLinks379 s.flushLinksFn = s.flushLinks
363 s.sink = newPresentSink(s)380 s.sink = newPresentSink(s)
364 s.capturedRing = newPostRing(d, capturedRingSize, s.applyCaptured)381 s.capturedRing = newPostRing(d, capturedRingSize, s.applyCaptured)
382+ s.casts = opts.Casts
383+ s.castSessions, s.castByCore = map[uint64]*castSession{}, map[uint64]*castSession{}
384+ s.castRing = newPostRing(d, castRingSize, s.applyCast)
385+ s.castInvFn = s.publishCastInventory
365 s.leaseDevices = map[string]*leaseDevice{}386 s.leaseDevices = map[string]*leaseDevice{}
366 s.pendingLeases = map[uint64]*leaseObject{}387 s.pendingLeases = map[uint64]*leaseObject{}
367 s.pendingLeaseCards = map[uint64]string{}388 s.pendingLeaseCards = map[uint64]string{}
@@ -553,6 +574,11 @@ func (s *Server) Run(ctx context.Context) error {
553 wg.Add(1)574 wg.Add(1)
554 go func() { defer wg.Done(); s.syncWait.run(ctx) }()575 go func() { defer wg.Done(); s.syncWait.run(ctx) }()
555 }576 }
577+ if s.casts != nil && s.channels.CastCommands != nil {
578+ wg.Add(1)
579+ go func() { defer wg.Done(); s.forwardCasts(ctx.Done()) }()
580+ }
581+ s.castInventoryChanged()
556 err := s.display.Run(ctx)582 err := s.display.Run(ctx)
557 s.stopSecurityContexts()583 s.stopSecurityContexts()
558 wg.Wait()584 wg.Wait()
@@ -710,6 +736,7 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) {
710 l.add(c)736 l.add(c)
711 s.flushLink(l)737 s.flushLink(l)
712 }738 }
739+ s.castCommitted(c.ID)
713 }740 }
714 741
715 // maxCommandBatch bounds how many queued commands one display round trip applies.742 // maxCommandBatch bounds how many queued commands one display round trip applies.
M internal/adapters/wayland/session_lock.go +1 -0
@@ -91,6 +91,7 @@ func (l *sessionLock) UnlockAndDestroy(r *extsessionlock.ExtSessionLockV1) {
9191 s.lockReadiness.End(l.state.Generation)
9292 s.sessionLock = nil
9393 s.refreshToplevels()
94+ s.castInventoryChanged() // the windows are listed again
9495 s.updateWorkspaceManagers(s.workspaceSnapshot)
9596 for _, surface := range l.surfaces {
9697 surface.unmap()
@@ -91,6 +91,7 @@ func (l *sessionLock) UnlockAndDestroy(r *extsessionlock.ExtSessionLockV1) {
91 s.lockReadiness.End(l.state.Generation)91 s.lockReadiness.End(l.state.Generation)
92 s.sessionLock = nil92 s.sessionLock = nil
93 s.refreshToplevels()93 s.refreshToplevels()
94+ s.castInventoryChanged() // the windows are listed again
94 s.updateWorkspaceManagers(s.workspaceSnapshot)95 s.updateWorkspaceManagers(s.workspaceSnapshot)
95 for _, surface := range l.surfaces {96 for _, surface := range l.surfaces {
96 surface.unmap()97 surface.unmap()
M internal/adapters/wayland/xdg.go +5 -0
@@ -249,6 +249,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
249249 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Modal: w.modal, Remap: w.gen > 1})
250250 x.server.syncInhibitors()
251251 x.server.toplevelChanged(w)
252+ x.server.castInventoryChanged()
252253 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
253254 } else if !buffer && w.mapped {
254255 w.unmap()
@@ -339,6 +340,9 @@ func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
339340 }
340341 t.w.title = title
341342 t.w.xdg.server.toplevelChanged(t.w)
343+ if t.w.mapped {
344+ t.w.xdg.server.castInventoryChanged()
345+ }
342346 }
343347 func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
344348 if appID == t.w.appID {
@@ -348,6 +352,7 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
348352 if t.w.mapped {
349353 t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID})
350354 t.w.xdg.server.toplevelChanged(t.w)
355+ t.w.xdg.server.castInventoryChanged()
351356 }
352357 }
353358 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
@@ -249,6 +249,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
249 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Modal: w.modal, Remap: w.gen > 1})249 x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Modal: w.modal, Remap: w.gen > 1})
250 x.server.syncInhibitors()250 x.server.syncInhibitors()
251 x.server.toplevelChanged(w)251 x.server.toplevelChanged(w)
252+ x.server.castInventoryChanged()
252 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")253 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
253 } else if !buffer && w.mapped {254 } else if !buffer && w.mapped {
254 w.unmap()255 w.unmap()
@@ -339,6 +340,9 @@ func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
339 }340 }
340 t.w.title = title341 t.w.title = title
341 t.w.xdg.server.toplevelChanged(t.w)342 t.w.xdg.server.toplevelChanged(t.w)
343+ if t.w.mapped {
344+ t.w.xdg.server.castInventoryChanged()
345+ }
342 }346 }
343 func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {347 func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
344 if appID == t.w.appID {348 if appID == t.w.appID {
@@ -348,6 +352,7 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
348 if t.w.mapped {352 if t.w.mapped {
349 t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID})353 t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID})
350 t.w.xdg.server.toplevelChanged(t.w)354 t.w.xdg.server.toplevelChanged(t.w)
355+ t.w.xdg.server.castInventoryChanged()
351 }356 }
352 }357 }
353 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}358 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
M internal/ports/screencast_messages.go +2 -1
@@ -13,7 +13,8 @@ const (
1313 CastOpen CastCommandKind = iota + 1
1414 // CastFrame asks for one frame into buffer Buf.
1515 CastFrame
16- // CastClose ends cast ID; wayland answers with CastStopped.
16+ // CastClose ends cast ID. Wayland sends no CastStopped for it; a frame
17+ // still in flight is answered with its CastFrameDone as usual.
1718 CastClose
1819 )
1920
@@ -13,7 +13,8 @@ const (
13 CastOpen CastCommandKind = iota + 113 CastOpen CastCommandKind = iota + 1
14 // CastFrame asks for one frame into buffer Buf.14 // CastFrame asks for one frame into buffer Buf.
15 CastFrame15 CastFrame
16- // CastClose ends cast ID; wayland answers with CastStopped.16+ // CastClose ends cast ID. Wayland sends no CastStopped for it; a frame
17+ // still in flight is answered with its CastFrameDone as usual.
17 CastClose18 CastClose
18 )19 )
19 20

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 24

Tags 10

↑↓ moveenter openesc close