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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +1193 -5
M Makefile +1 -1
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 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=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 | |
| 73 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # 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=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 |
| 73 | check: vet test arch fakes-check log-check perf-check | 73 | check: vet test arch fakes-check log-check perf-check | ||
| 74 | 74 | ||||
| 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 75 | # 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) { | |||
| 206 | 206 | l.closed(id) | |
| 207 | 207 | } | |
| 208 | 208 | s.windowCaptureGone(id) | |
| 209 | + | s.castInventoryChanged() | |
| 209 | 210 | } | |
| 210 | 211 | ||
| 211 | 212 | // 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 | |||
| 155 | 155 | if p.Flip != nil { | |
| 156 | 156 | s.flips[p.Output] = *p.Flip | |
| 157 | 157 | s.presentFlip(p.Output, p.Flip) | |
| 158 | + | s.castFlip(p.Output, p.Flip) | |
| 158 | 159 | } | |
| 159 | 160 | } | |
| 160 | 161 | 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.Flip | 156 | 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) { | |||
| 113 | 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 | 124 | type extraSources struct{ s *Server } | |
| @@ -447,6 +453,9 @@ func (s *Server) refreshSessions() { | |||
| 447 | 453 | for c := range s.captureSessions { | |
| 448 | 454 | c.refresh() | |
| 449 | 455 | } | |
| 456 | + | for _, c := range s.castSessions { | |
| 457 | + | c.refresh() | |
| 458 | + | } | |
| 450 | 459 | } | |
| 451 | 460 | ||
| 452 | 461 | // endCaptureSessions stops every session, and its exclusion with it, when | |
| @@ -459,6 +468,11 @@ func (s *Server) endCaptureSessions() { | |||
| 459 | 468 | } | |
| 460 | 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 | 478 | // outputGone ends the sessions of an unplugged output without waiting for | |
| @@ -470,10 +484,20 @@ func (s *Server) outputGone(o *output) { | |||
| 470 | 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 | 495 | // captureState applies what core decided about a session. | |
| 476 | 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 | 501 | c := s.sessionsByID[st.ID] | |
| 478 | 502 | if c == nil { | |
| 479 | 503 | 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, when | 461 | // 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 for | 478 | // 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 | return | 503 | return | ||
M internal/adapters/wayland/outputmanagement.go +1 -0
| @@ -189,6 +189,7 @@ func (s *Server) setOutputHeads(h ports.OutputHeads) { | |||
| 189 | 189 | return | |
| 190 | 190 | } | |
| 191 | 191 | s.outputHeads = h | |
| 192 | + | s.castInventoryChanged() | |
| 192 | 193 | s.refreshOutputManagers() | |
| 193 | 194 | } | |
| 194 | 195 | func (s *Server) refreshOutputManagers() { | |
| @@ -189,6 +189,7 @@ func (s *Server) setOutputHeads(h ports.OutputHeads) { | |||||
| 189 | return | 189 | return | ||
| 190 | } | 190 | } | ||
| 191 | s.outputHeads = h | 191 | 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) { | |||
| 160 | 160 | // changed ones resent. | |
| 161 | 161 | func (s *Server) setOutputs(c ports.SetOutputs) { | |
| 162 | 162 | changed := !slices.Equal(s.outputPlaces, c.Outputs) | |
| 163 | + | focusChanged := s.focusedOutput != c.Focused | |
| 163 | 164 | s.outputPlaces = append(ports.Layout(nil), c.Outputs...) | |
| 164 | 165 | defer func() { | |
| 165 | 166 | if changed { | |
| 166 | 167 | s.refreshOutputManagers() | |
| 167 | 168 | } | |
| 169 | + | if changed || focusChanged { | |
| 170 | + | s.castInventoryChanged() | |
| 171 | + | } | |
| 168 | 172 | }() | |
| 169 | 173 | s.focusedOutput = c.Focused | |
| 170 | 174 | 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.Focused | 173 | 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 { | |||
| 48 | 48 | // nil lets every client capture, unless the session is protected. The | |
| 49 | 49 | // caller keeps it current (captureallow.Store.Run). | |
| 50 | 50 | 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 | 54 | // LogDamage logs the damage of every fresh client commit at debug level | |
| 52 | 55 | // (--debug=wayland-damage). | |
| 53 | 56 | LogDamage bool | |
| @@ -78,6 +81,9 @@ type Channels struct { | |||
| 78 | 81 | // never block the backend; standalone tests may use smaller channels. | |
| 79 | 82 | Captures chan<- ports.CaptureRequest | |
| 80 | 83 | 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 | 87 | // OutputFormats are the outputs' direct scanout formats, offered in | |
| 82 | 88 | // dmabuf feedback to fullscreen surfaces. | |
| 83 | 89 | OutputFormats <-chan ports.OutputFormats | |
| @@ -122,9 +128,20 @@ type Server struct { | |||
| 122 | 128 | // sessionsByID are the sessions registered with core (imagecopy.go); | |
| 123 | 129 | // excl is the one live exclusion (exclusion.go); ownUID the compositor's. | |
| 124 | 130 | 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 | |
| 128 | 145 | // captureAllow, peer and peers decide mayCapture (captureauth.go): | |
| 129 | 146 | // peers keeps each client's pidfd until it is destroyed. | |
| 130 | 147 | captureAllow *captureallow.Store | |
| @@ -362,6 +379,10 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 362 | 379 | s.flushLinksFn = s.flushLinks | |
| 363 | 380 | s.sink = newPresentSink(s) | |
| 364 | 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 | 386 | s.leaseDevices = map[string]*leaseDevice{} | |
| 366 | 387 | s.pendingLeases = map[uint64]*leaseObject{} | |
| 367 | 388 | s.pendingLeaseCards = map[uint64]string{} | |
| @@ -553,6 +574,11 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 553 | 574 | wg.Add(1) | |
| 554 | 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 | 582 | err := s.display.Run(ctx) | |
| 557 | 583 | s.stopSecurityContexts() | |
| 558 | 584 | wg.Wait() | |
| @@ -710,6 +736,7 @@ func (s *Server) emitContent(c ports.SurfaceContent, d damage) { | |||
| 710 | 736 | l.add(c) | |
| 711 | 737 | s.flushLink(l) | |
| 712 | 738 | } | |
| 739 | + | s.castCommitted(c.ID) | |
| 713 | 740 | } | |
| 714 | 741 | ||
| 715 | 742 | // 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. The | 48 | // 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.Store | 50 | 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 level | 54 | // LogDamage logs the damage of every fresh client commit at debug level | ||
| 52 | // (--debug=wayland-damage). | 55 | // (--debug=wayland-damage). | ||
| 53 | LogDamage bool | 56 | 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.CaptureRequest | 82 | Captures chan<- ports.CaptureRequest | ||
| 80 | Captured <-chan ports.CaptureDone | 83 | 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 in | 87 | // 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.OutputFormats | 89 | 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]*captureSession | 130 | sessionsByID map[uint64]*captureSession | ||
| 125 | - | nextSession uint64 | 131 | + | // Screencast (screencast.go): casts by the adapter's ID and by the ID |
| 126 | - | excl *exclusion | 132 | + | // core knows them by; castRing carries the adapter's commands to the |
| 127 | - | ownUID uint32 | 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 | |||
| 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.Store | 147 | captureAllow *captureallow.Store | ||
| @@ -362,6 +379,10 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 362 | s.flushLinksFn = s.flushLinks | 379 | 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) { | |||
| 91 | 91 | s.lockReadiness.End(l.state.Generation) | |
| 92 | 92 | s.sessionLock = nil | |
| 93 | 93 | s.refreshToplevels() | |
| 94 | + | s.castInventoryChanged() // the windows are listed again | |
| 94 | 95 | s.updateWorkspaceManagers(s.workspaceSnapshot) | |
| 95 | 96 | for _, surface := range l.surfaces { | |
| 96 | 97 | 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 = nil | 92 | 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) { | |||
| 249 | 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 | 250 | x.server.syncInhibitors() | |
| 251 | 251 | x.server.toplevelChanged(w) | |
| 252 | + | x.server.castInventoryChanged() | |
| 252 | 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 | 254 | } else if !buffer && w.mapped { | |
| 254 | 255 | w.unmap() | |
| @@ -339,6 +340,9 @@ func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { | |||
| 339 | 340 | } | |
| 340 | 341 | t.w.title = title | |
| 341 | 342 | t.w.xdg.server.toplevelChanged(t.w) | |
| 343 | + | if t.w.mapped { | |
| 344 | + | t.w.xdg.server.castInventoryChanged() | |
| 345 | + | } | |
| 342 | 346 | } | |
| 343 | 347 | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |
| 344 | 348 | if appID == t.w.appID { | |
| @@ -348,6 +352,7 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |||
| 348 | 352 | if t.w.mapped { | |
| 349 | 353 | t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID}) | |
| 350 | 354 | t.w.xdg.server.toplevelChanged(t.w) | |
| 355 | + | t.w.xdg.server.castInventoryChanged() | |
| 351 | 356 | } | |
| 352 | 357 | } | |
| 353 | 358 | 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 = title | 341 | 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 ( | |||
| 13 | 13 | CastOpen CastCommandKind = iota + 1 | |
| 14 | 14 | // CastFrame asks for one frame into buffer Buf. | |
| 15 | 15 | 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 | 18 | CastClose | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| @@ -13,7 +13,8 @@ const ( | |||||
| 13 | CastOpen CastCommandKind = iota + 1 | 13 | CastOpen CastCommandKind = iota + 1 | ||
| 14 | // CastFrame asks for one frame into buffer Buf. | 14 | // CastFrame asks for one frame into buffer Buf. | ||
| 15 | CastFrame | 15 | 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 | CastClose | 18 | CastClose | ||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||