detached a646b50

feat(capture): add authorized recording sessions and offscreen workspace capture (#70)

* feat(capture): add authorized recording sessions and offscreen workspace capture

* fix(wayland): announce layer target scale before configure

* fix(capture): hold client buffers until child GPU reads complete

Carry immutable outstanding child-read sequences in output reports so the stale display-report timeout cannot release buffers or explicit sync points while the separate child device still samples them. Publish holds before DRM flips and headless waits without advancing display reads.

Cover a fence beyond 100 ms, completion, failed readback, per-window isolation, scanout preservation and zero-allocation steady-state reports.

* docs(capture): clarify hidden pipeline budgets and session teardown

Describe separate child renderer and readback budgets, demand-driven lifetime, display scanout preservation and whole-workspace geometry limits. Correct the stopped event documentation to match attachment teardown and layer closure; regenerated bindings are unchanged.

* build(deps): pin purego-libwayland peer credentials with effective-ID test

* fix(capture): close child device on terminal fence error

A failed child fence (POLLERR or a broken poll) never signals, so its
non-expiring ChildReads hold lasted forever and busy() blocked session
retirement and replacement. Distinguish pending from terminal fences: on
a terminal error cancel and join the child pipeline, close the renderer,
then clear the holds so the next report lifts them.

* fix(drm): report child read holds independently of display fences

Child holds do not expire, but their completion was reported only when no
display commit was pending and no uncommitted frame fence was outstanding,
so a stuck display fence kept a finished child read held. Report child-hold
changes alone at the last safe display Seen while the display is busy.

* perf(headless): cache child-hold report snapshots

Every pending hidden-capture frame cloned Seen and ChildReads even when
unchanged. Keep immutable snapshots and clone only on change, as the DRM
output does.
commit
a646b50696ff0b2ba6d1b0fb044f38ad6466f2f0
author
bnema · · 2026-09-30 16:23 UTC
committer
GitHub · · 2026-09-30 16:23 UTC
parent
a61214e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
53 files · +5949 -46
M docs/headless.md +7 -1
@@ -9,7 +9,13 @@ WAYLAND_DISPLAY=<name from the log> foot
99
1010 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.
1111
12-`/tmp/neferwl-shots/latest.png` shows the current frame. `--timeout 5s` stops NeferWL after 5 seconds.
12+Nefercap uses the private `neferwl_capture_manager_v1` protocol for recording sessions. A session targets an output, a fixed output-local region or a workspace's stable identity. Workspace recording uses the workspace's real layout rather than overview previews. While hidden, it uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires a whole-workspace target and requests matching its exact physical dimensions; cropped targets or requests are unsupported.
13+
14+The compositor draws a red border around the visible recording target. An explicitly authorized HUD stays visible over fullscreen without keyboard focus. The session's captures omit the border, HUD and its popups; other clients' standard captures include them. HUD authorization requires the session's random token and a matching socket peer UID, never a layer namespace or app-id. Output removal, power-off, owner disconnect and a five-second keepalive timeout stop the session.
15+
16+Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. Clean capture can require two compositions to preserve the displayed z-order.
17+
18+`/tmp/neferwl-shots/latest.png` shows the current frame, including the recording border and HUD. `--timeout 5s` stops NeferWL after 5 seconds.
1319
1420 `--input <path>`, or `--input -` for stdin, plays an input script. One command per line:
1521
M docs/performance.md +1 -1
@@ -8,7 +8,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen
88 - **Allocation-free steady-state presentation.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
99 - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz.
1010 - **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements.
11-- **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. GPU resources are allocated on first capture and retained until the output stops. Copies have a separate completion fence but still cost GPU time and bandwidth; capture forces composition instead of scanout. There is no per-client reservation of the shared request limit.
11+- **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. Display capture resources are allocated on first capture and retained until the output stops. Hidden workspace capture uses a separate, demand-driven child renderer and pipeline with its own worker and two staging slots (up to 128 MiB host-visible memory), plus child render targets; each child image is capped at 256 MiB and 16384 pixels per side. The child retires once unused and idle, and is recreated for session or size changes. Copies cost GPU time and bandwidth. Capture of the displayed frame forces composition; hidden workspace capture can keep display scanout. Offscreen capture currently serves only a whole-workspace target and requests matching its exact physical dimensions, not crops. There is no per-client reservation of the shared request limit.
1212 - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
1313
1414 Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE, which the Arch packages set on `/usr/bin/neferwl` at install. Apps launched by NeferWL inherit neither. Set `performance.realtime = false` to opt out. A binary built with `make bin` runs at normal priority unless you grant it: `sudo setcap cap_sys_nice+ep bin/neferwl`.
M go.mod +1 -1
@@ -4,7 +4,7 @@ go 1.27
44
55 require (
66 github.com/bnema/purego v0.13.0-bnema.1
7- github.com/bnema/purego-libwayland v0.6.0
7+ github.com/bnema/purego-libwayland v0.6.1-0.20260930144645-155613350d00
88 github.com/bnema/purego-vulkan v0.6.0
99 github.com/bnema/wlturbo v0.2.0
1010 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -2
@@ -1,7 +1,7 @@
11 github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=
22 github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
3-github.com/bnema/purego-libwayland v0.6.0 h1:ZsyHZ/9yX5vgTx7j3BRrFXW0vZ05ifFiaabxMrIqriE=
4-github.com/bnema/purego-libwayland v0.6.0/go.mod h1:+2zIEBX+iNlRB27xrm5Z8Dnd+cmqX+1AYvuwguxK2dg=
3+github.com/bnema/purego-libwayland v0.6.1-0.20260930144645-155613350d00 h1:I2pUc9TQLINOfENKaFik/BuVUMpJoiRHH/NuArw0Dhk=
4+github.com/bnema/purego-libwayland v0.6.1-0.20260930144645-155613350d00/go.mod h1:+2zIEBX+iNlRB27xrm5Z8Dnd+cmqX+1AYvuwguxK2dg=
55 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=
66 github.com/bnema/purego-vulkan v0.6.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4=
77 github.com/bnema/wlturbo v0.2.0 h1:gZPe81TrrrYW8xrbZpEvZnDI7VQBm60+dgAapbXJyRQ=
A internal/adapters/capture/clean.go +148 -0
@@ -0,0 +1,148 @@
1+package capture
2+
3+import (
4+ "errors"
5+ "slices"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// ErrSessionInactive fails a Clean request whose session is not the one the
11+// scene carries. Serving it as a normal capture would leak the session's
12+// HUD, its native border, or a workspace it no longer targets.
13+var ErrSessionInactive = errors.New("capture session is not active")
14+
15+// ErrSessionStale fails a Clean request stamped with a session revision newer
16+// than the scene's: the scene predates a change of what a clean capture must
17+// hide (a new HUD layer, popup, target), so its exclusion list could leak.
18+var ErrSessionStale = errors.New("capture session scene is older than the request")
19+
20+// cleaner derives the scene of clean captures: the frame without the native
21+// session border and without the excluded layers and popups. Its scratch
22+// slices are reused, so a warmed output allocates nothing.
23+type cleaner struct {
24+ windows []ports.SceneWindow
25+ layers []ports.SceneLayer
26+}
27+
28+// Shows reports whether a content change of id needs a new frame: the
29+// displayed scene draws it, or the hidden workspace of a capture session does
30+// (its child render and the reads it releases). Flips and Shown still count
31+// the displayed scene only.
32+func Shows(s ports.Scene, id ports.WindowID) bool {
33+ return s.Shows(id) || s.CaptureScene != nil && s.CaptureScene.Shows(id)
34+}
35+
36+// BorderVisible reports whether the scene's native session border draws at
37+// least one pixel: a color, and a width that survives the renderer's clamp to
38+// half the smaller side of the target. The renderer draws by the same rule.
39+func BorderVisible(s ports.Scene) bool {
40+ c := s.Capture
41+ return c != nil && c.BorderColor != "" && min(c.BorderWidth, c.TargetRect.W/2, c.TargetRect.H/2) > 0
42+}
43+
44+// cleanDiffers reports whether the clean scene differs from the displayed
45+// one: a native border is drawn, or an excluded surface is in the scene.
46+// When it does not, clean requests are served from the displayed frame.
47+func cleanDiffers(s ports.Scene) bool {
48+ c := s.Capture
49+ if c == nil {
50+ return false
51+ }
52+ if BorderVisible(s) {
53+ return true
54+ }
55+ if len(c.Excluded) == 0 {
56+ return false
57+ }
58+ for _, w := range s.Windows {
59+ if slices.Contains(c.Excluded, w.ID) {
60+ return true
61+ }
62+ }
63+ for _, l := range s.Layers {
64+ if slices.Contains(c.Excluded, l.ID) {
65+ return true
66+ }
67+ }
68+ return false
69+}
70+
71+// scene returns s without Capture (so no border) and without the excluded
72+// windows (popups included) and layers. Seq is 0, so the renderer redraws
73+// the whole target instead of reusing a displayed frame. The result is valid
74+// until the next call. Without a session s is returned unchanged.
75+func (c *cleaner) scene(s ports.Scene) ports.Scene {
76+ if s.Capture == nil {
77+ return s
78+ }
79+ ex := s.Capture.Excluded
80+ s.Capture, s.Seq = nil, 0
81+ if len(ex) == 0 {
82+ return s
83+ }
84+ c.windows = c.windows[:0]
85+ for _, w := range s.Windows {
86+ if !slices.Contains(ex, w.ID) {
87+ c.windows = append(c.windows, w)
88+ }
89+ }
90+ c.layers = c.layers[:0]
91+ for _, l := range s.Layers {
92+ if !slices.Contains(ex, l.ID) {
93+ c.layers = append(c.layers, l)
94+ }
95+ }
96+ s.Windows, s.Layers = c.windows, c.layers
97+ return s
98+}
99+
100+// CleanScene is the scene a Clean capture is taken from; see Split.
101+func (p *Pipeline) CleanScene(s ports.Scene) ports.Scene { return p.clean.scene(s) }
102+
103+// Split answers or orders the requests of one output frame. Clean requests
104+// of a session other than the scene's, or with no session in the scene, are
105+// failed with ErrSessionInactive, those newer than the scene's Revision with
106+// ErrSessionStale (never served as normal captures), and removed. The rest is reordered in place: normal is the prefix, drawn from
107+// the displayed frame; clean is the following requests, drawn from CleanScene
108+// rendered first, or from the child scene when s.CaptureScene is set (see
109+// Offscreen). clean is empty when the clean frame would equal the displayed
110+// one, its requests then stay in normal. The entries past
111+// len(normal)+len(clean) are zeroed. No allocation.
112+func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, clean []ports.CaptureRequest) {
113+ n := 0
114+ for _, q := range reqs {
115+ if q.Clean {
116+ var err error
117+ switch {
118+ case s.Capture == nil || q.Session != s.Capture.Session:
119+ err = ErrSessionInactive
120+ case s.Capture.Revision < q.CaptureRevision:
121+ err = ErrSessionStale
122+ }
123+ if err != nil {
124+ Fail(p.ctx, q, err, p.replies)
125+ continue
126+ }
127+ }
128+ reqs[n] = q
129+ n++
130+ }
131+ clear(reqs[n:])
132+ // A hidden workspace (CaptureScene) is always a separate frame.
133+ if s.CaptureScene == nil && !cleanDiffers(s) {
134+ return reqs[:n], nil
135+ }
136+ j := 0
137+ for i := range n {
138+ if !reqs[i].Clean {
139+ reqs[i], reqs[j] = reqs[j], reqs[i]
140+ j++
141+ }
142+ }
143+ return reqs[:j], reqs[j:n]
144+}
145+
146+// Handed reports a zeroed request: one Split failed, or one already given to
147+// the worker. The owner never answers it again.
148+func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil }
A internal/adapters/capture/clean_test.go +157 -0
@@ -0,0 +1,157 @@
1+package capture
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func cleanTestScene() ports.Scene {
12+ return ports.Scene{
13+ Seq: 5,
14+ Windows: []ports.SceneWindow{
15+ {ID: 1}, {ID: 20, Popup: true, OverLayers: true}, {ID: 21, Popup: true, OverLayers: true}, {ID: 3},
16+ },
17+ Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}, {ID: 11, Layer: ports.LayerTop}, {ID: 12, Layer: ports.LayerOverlay}},
18+ Capture: &ports.SceneCapture{
19+ Session: 7, BorderWidth: 2, BorderColor: ports.CaptureBorderColor,
20+ Excluded: []ports.WindowID{10, 20},
21+ },
22+ }
23+}
24+
25+func TestCleanSceneDropsOnlyExcludedAndBorder(t *testing.T) {
26+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
27+ defer p.Close(nil)
28+ s := cleanTestScene()
29+ c := p.CleanScene(s)
30+ require.Nil(t, c.Capture)
31+ require.Zero(t, c.Seq)
32+ require.Equal(t, []ports.SceneWindow{{ID: 1}, {ID: 21, Popup: true, OverLayers: true}, {ID: 3}}, c.Windows)
33+ require.Equal(t, []ports.SceneLayer{{ID: 11, Layer: ports.LayerTop}, {ID: 12, Layer: ports.LayerOverlay}}, c.Layers)
34+ // The displayed scene is not mutated.
35+ require.Len(t, s.Windows, 4)
36+ require.Len(t, s.Layers, 3)
37+ require.NotNil(t, s.Capture)
38+ require.Equal(t, uint64(5), s.Seq)
39+}
40+
41+func TestCleanSceneWithoutSessionUnchangedNoAlloc(t *testing.T) {
42+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
43+ defer p.Close(nil)
44+ s := cleanTestScene()
45+ s.Capture = nil
46+ require.Equal(t, s, p.CleanScene(s))
47+ reqs := make([]ports.CaptureRequest, 3)
48+ for i := range reqs {
49+ reqs[i].ID = uint64(i + 1)
50+ }
51+ if allocs := testing.AllocsPerRun(100, func() {
52+ _ = p.CleanScene(s)
53+ p.Split(s, reqs)
54+ }); allocs != 0 {
55+ t.Fatalf("no session: %.1f allocs, want 0", allocs)
56+ }
57+}
58+
59+func TestCleanSceneWarmedNoAlloc(t *testing.T) {
60+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
61+ defer p.Close(nil)
62+ s := cleanTestScene()
63+ p.CleanScene(s)
64+ if allocs := testing.AllocsPerRun(100, func() { _ = p.CleanScene(s) }); allocs != 0 {
65+ t.Fatalf("warmed clean scene: %.1f allocs, want 0", allocs)
66+ }
67+}
68+
69+func TestSplitOrdersAndFailsClosed(t *testing.T) {
70+ replies := make(chan ports.CaptureDone, 8)
71+ p := NewPipeline(context.Background(), replies)
72+ defer p.Close(nil)
73+ s := cleanTestScene()
74+ reqs := []ports.CaptureRequest{
75+ {ID: 1, Clean: true, Session: 7},
76+ {ID: 2},
77+ {ID: 3, Clean: true, Session: 8}, // another session: never served
78+ {ID: 4, Clean: true, Session: 7},
79+ {ID: 5},
80+ }
81+ normal, clean := p.Split(s, reqs)
82+ ids := func(q []ports.CaptureRequest) (out []uint64) {
83+ for _, r := range q {
84+ out = append(out, r.ID)
85+ }
86+ return out
87+ }
88+ require.ElementsMatch(t, []uint64{2, 5}, ids(normal))
89+ require.ElementsMatch(t, []uint64{1, 4}, ids(clean))
90+ for _, q := range clean {
91+ require.True(t, q.Clean)
92+ }
93+ for _, q := range normal {
94+ require.False(t, q.Clean)
95+ }
96+ got := awaitCapture(t, replies)
97+ require.Equal(t, uint64(3), got.ID)
98+ require.ErrorIs(t, got.Err, ErrSessionInactive)
99+ for _, q := range reqs[len(normal)+len(clean):] {
100+ require.True(t, Handed(q), "tail must be zeroed")
101+ }
102+}
103+
104+func TestSplitCleanWithoutSceneSessionFails(t *testing.T) {
105+ replies := make(chan ports.CaptureDone, 4)
106+ p := NewPipeline(context.Background(), replies)
107+ defer p.Close(nil)
108+ s := cleanTestScene()
109+ s.Capture = nil
110+ normal, clean := p.Split(s, []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}})
111+ require.Len(t, normal, 1)
112+ require.Equal(t, uint64(2), normal[0].ID)
113+ require.Empty(t, clean)
114+ got := awaitCapture(t, replies)
115+ require.Equal(t, uint64(1), got.ID)
116+ require.ErrorIs(t, got.Err, ErrSessionInactive)
117+}
118+
119+// A session with nothing to hide (no border, no excluded surface in the
120+// scene) is served from the displayed frame: no second composition.
121+func TestSplitNoSecondCompositionWhenCleanEqualsDisplayed(t *testing.T) {
122+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
123+ defer p.Close(nil)
124+ s := cleanTestScene()
125+ s.Capture.BorderWidth, s.Capture.Excluded = 0, []ports.WindowID{99}
126+ normal, clean := p.Split(s, []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}})
127+ require.Len(t, normal, 2)
128+ require.Empty(t, clean)
129+}
130+
131+func TestSplitStandardCapturesKeepSessionScene(t *testing.T) {
132+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
133+ defer p.Close(nil)
134+ normal, clean := p.Split(cleanTestScene(), []ports.CaptureRequest{{ID: 1}, {ID: 2}})
135+ require.Len(t, normal, 2)
136+ require.Empty(t, clean)
137+}
138+
139+// A request stamped with a revision the scene has not reached is refused:
140+// the scene's exclusion list may predate a newly mapped HUD layer.
141+func TestSplitStaleRevisionFailsClosed(t *testing.T) {
142+ replies := make(chan ports.CaptureDone, 4)
143+ p := NewPipeline(context.Background(), replies)
144+ defer p.Close(nil)
145+ s := cleanTestScene()
146+ s.Capture.Revision = 3
147+ normal, clean := p.Split(s, []ports.CaptureRequest{
148+ {ID: 1, Clean: true, Session: 7, CaptureRevision: 4},
149+ {ID: 2, Clean: true, Session: 7, CaptureRevision: 3},
150+ {ID: 3, Clean: true, Session: 7, CaptureRevision: 2},
151+ })
152+ require.Empty(t, normal)
153+ require.Len(t, clean, 2)
154+ got := awaitCapture(t, replies)
155+ require.Equal(t, uint64(1), got.ID)
156+ require.ErrorIs(t, got.Err, ErrSessionStale)
157+}
A internal/adapters/capture/offscreen.go +429 -0
@@ -0,0 +1,429 @@
1+package capture
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "image"
8+ "maps"
9+ "math"
10+ "os"
11+
12+ "github.com/bnema/neferwl/internal/adapters/syncfile"
13+ "github.com/bnema/neferwl/internal/ports"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// Offscreen capture: a session of a workspace that is not on screen is drawn
18+// by a dedicated child renderer from Scene.CaptureScene, only for clean
19+// requests and never shown. The child is a separate renderer instance: it has
20+// its own Vulkan device, so its fences are not ordered against the display's.
21+// It is created on the first clean request of the session and closed when the
22+// scene stops carrying CaptureScene (once idle), when the session or the frame
23+// size changes, and with the output.
24+
25+const (
26+ // MaxOffscreenDim bounds either side of the child image, in pixels.
27+ MaxOffscreenDim = 16384
28+ // MaxOffscreenBytes bounds one child image (w*h*4).
29+ MaxOffscreenBytes = 256 << 20
30+ // maxChildFences bounds child renders whose GPU reads are not finished.
31+ // It equals the renderer's frame slots (two): a third Render would make
32+ // the owner wait for the GPU, so it is refused before it is recorded.
33+ maxChildFences = 2
34+)
35+
36+var (
37+ // ErrOffscreenUnavailable fails a clean request whose source is a hidden
38+ // workspace that this output cannot draw (no child renderer factory).
39+ ErrOffscreenUnavailable = errors.New("workspace capture source is unavailable")
40+ // ErrOffscreenGeometry fails a request the child frame cannot serve.
41+ ErrOffscreenGeometry = errors.New("workspace capture geometry is unsupported")
42+ // ErrOffscreenBusy fails a request while the child is still busy with
43+ // another session or size, or has too many renders in flight.
44+ ErrOffscreenBusy = errors.New("workspace capture is busy")
45+)
46+
47+// fenceSlot is one child render whose GPU reads may not be finished: its sync
48+// file and the read ceiling it implies. ceil holds, for each window the render
49+// drew, the content Seq the renderer was given. The map is reused for the
50+// output's lifetime (cleared and refilled), so a steady state allocates
51+// nothing.
52+type fenceSlot struct {
53+ fd *os.File
54+ ceil map[ports.WindowID]uint64
55+}
56+
57+// release closes the fence and forgets the ceiling, keeping the map.
58+func (s *fenceSlot) release() {
59+ if s.fd != nil {
60+ s.fd.Close()
61+ s.fd = nil
62+ }
63+ clear(s.ceil)
64+}
65+
66+// offscreen is owned by the goroutine of its Pipeline.
67+type offscreen struct {
68+ factory func(w, h int) (ports.Renderer, error)
69+ r ports.Renderer
70+ p *Pipeline // leases of r
71+ w, h int
72+ session uint64
73+ // slots[:nf] are the unfinished child renders, oldest first.
74+ slots [maxChildFences]fenceSlot
75+ nf int
76+ clean cleaner
77+ // capped is the scratch map CapHiddenSeen returns.
78+ capped map[ports.WindowID]uint64
79+ // reads is the scratch map of the oldest outstanding child reads.
80+ reads map[ports.WindowID]uint64
81+}
82+
83+// EnableOffscreen lets the pipeline serve clean requests of a hidden
84+// workspace with renderers made by factory. Without it they fail closed
85+// with ErrOffscreenUnavailable.
86+func (p *Pipeline) EnableOffscreen(factory func(w, h int) (ports.Renderer, error)) {
87+ p.off = &offscreen{factory: factory}
88+}
89+
90+// HiddenCompleted wakes the owner when a child capture finished; nil (blocks
91+// forever in a select) while there is no child.
92+func (p *Pipeline) HiddenCompleted() <-chan *batch {
93+ if p == nil || p.off == nil || p.off.p == nil {
94+ return nil
95+ }
96+ return p.off.p.completed
97+}
98+
99+// RecycleHidden returns a completed child lease.
100+func (p *Pipeline) RecycleHidden(b *batch) {
101+ if p != nil && p.off != nil && p.off.p != nil {
102+ p.off.p.Recycle(b, p.off.r)
103+ }
104+}
105+
106+// HiddenReading reports whether the child may still read client buffers. The
107+// display's fences do not cover it.
108+func (p *Pipeline) HiddenReading() bool {
109+ if p == nil || p.off == nil {
110+ return false
111+ }
112+ p.off.reap()
113+ return p.off.nf > 0
114+}
115+
116+// CapHiddenSeen limits seen, the content Seq per window an output has read,
117+// to what the unfinished child renders were given: a client buffer newer than
118+// that may not be released yet on their account. Only windows a render drew
119+// are capped, each by the lowest ceiling among the unfinished renders that
120+// drew it (a window the render drew without content is capped at 0); every
121+// other window is untouched, so the display's own reads are never held back.
122+// The second result records the oldest outstanding read per window, even
123+// when Seen needs no cap: those buffers must not be released by a stale-report
124+// timeout. The last result says whether child reads remain, so the owner keeps
125+// reporting until they finish. Both maps are pipeline scratch, valid until
126+// the next call; copy them to keep them.
127+func (p *Pipeline) CapHiddenSeen(seen map[ports.WindowID]uint64) (map[ports.WindowID]uint64, map[ports.WindowID]uint64, bool) {
128+ if p == nil || p.off == nil {
129+ return seen, nil, false
130+ }
131+ o := p.off
132+ o.reap()
133+ if o.nf == 0 {
134+ return seen, nil, false
135+ }
136+ if o.reads == nil {
137+ o.reads = make(map[ports.WindowID]uint64)
138+ }
139+ clear(o.reads)
140+ hit := false
141+ for i := range o.nf {
142+ for id, c := range o.slots[i].ceil {
143+ if prev, ok := o.reads[id]; !ok || c < prev {
144+ o.reads[id] = c
145+ }
146+ if seen[id] > c {
147+ hit = true
148+ }
149+ }
150+ }
151+ if !hit {
152+ return seen, o.reads, true
153+ }
154+ if o.capped == nil {
155+ o.capped = make(map[ports.WindowID]uint64, len(seen))
156+ }
157+ clear(o.capped)
158+ maps.Copy(o.capped, seen)
159+ for i := range o.nf {
160+ for id, c := range o.slots[i].ceil {
161+ if v, ok := o.capped[id]; ok && v > c {
162+ o.capped[id] = c
163+ }
164+ }
165+ }
166+ return o.capped, o.reads, true
167+}
168+
169+// WaitHidden blocks until the child finished reading (headless outputs, which
170+// have no screen pacing frames anyway). Whatever happens, every fence is
171+// closed on return: after a failure or a cancelled ctx the rest is not waited
172+// for (the renderer's Close waits for the device before it frees anything).
173+func (p *Pipeline) WaitHidden(ctx context.Context) error {
174+ if p == nil || p.off == nil {
175+ return nil
176+ }
177+ o := p.off
178+ var first error
179+ for i := range o.nf {
180+ if first == nil {
181+ first = syncfile.Wait(ctx, o.slots[i].fd)
182+ }
183+ o.slots[i].release()
184+ }
185+ o.nf = 0
186+ return first
187+}
188+
189+// Retire closes the child once the scene no longer carries CaptureScene and
190+// nothing of it is in flight. Call it when a frame is prepared, when a child
191+// lease is recycled and on the idle housekeeping tick.
192+func (p *Pipeline) Retire(s ports.Scene) {
193+ if p != nil && p.off != nil && s.CaptureScene == nil && p.off.r != nil && !p.off.busy() {
194+ p.off.close()
195+ }
196+}
197+
198+// fenceState is what a zero-timeout poll says about a child render fence.
199+type fenceState int
200+
201+const (
202+ fencePending fenceState = iota // GPU work still running (or poll interrupted)
203+ fenceDone // finished: nothing is left to read
204+ fenceFailed // terminal: the GPU failed the work or poll broke
205+)
206+
207+// reap forgets the renders whose fence signalled, oldest first kept in order.
208+// A terminal fence error cannot be waited out, and its hold does not expire:
209+// the child pipeline is cancelled and joined, then the device is closed (which
210+// waits for the GPU), and only then are the holds cleared, so every later
211+// report lifts them. The next clean request builds a new child.
212+func (o *offscreen) reap() {
213+ k := 0
214+ failed := false
215+ for i := range o.nf {
216+ switch pollFence(o.slots[i].fd) {
217+ case fenceDone:
218+ o.slots[i].release()
219+ continue
220+ case fenceFailed:
221+ failed = true
222+ }
223+ if k != i {
224+ o.slots[k], o.slots[i] = o.slots[i], o.slots[k]
225+ }
226+ k++
227+ }
228+ o.nf = k
229+ if failed {
230+ o.close()
231+ }
232+}
233+
234+// pollFence polls a sync file without waiting. It does not allocate.
235+func pollFence(f *os.File) fenceState {
236+ fd := int32(f.Fd())
237+ if fd < 0 {
238+ return fenceDone // closed: poll would skip it, and nothing is left to wait for
239+ }
240+ var pfd [1]unix.PollFd
241+ pfd[0] = unix.PollFd{Fd: fd, Events: unix.POLLIN}
242+ n, err := unix.Poll(pfd[:], 0)
243+ return pollResult(n, err, pfd[0].Revents)
244+}
245+
246+// pollResult decides from a zero-timeout poll. A readable fence (or an invalid
247+// descriptor, which has nothing left to wait for) is done. An interrupted poll
248+// and a timeout are pending. An error status (POLLERR: the GPU failed the
249+// work) or any other poll error is terminal: it will never signal.
250+func pollResult(n int, err error, revents int16) fenceState {
251+ switch {
252+ case errors.Is(err, unix.EBADF):
253+ return fenceDone
254+ case errors.Is(err, unix.EINTR):
255+ return fencePending
256+ case err != nil:
257+ return fenceFailed
258+ case n <= 0:
259+ return fencePending
260+ case revents&unix.POLLERR != 0:
261+ return fenceFailed
262+ case revents&(unix.POLLIN|unix.POLLNVAL) != 0:
263+ return fenceDone
264+ }
265+ return fencePending
266+}
267+
268+func (o *offscreen) busy() bool {
269+ o.reap()
270+ if o.nf > 0 {
271+ return true
272+ }
273+ if o.p != nil {
274+ o.p.reclaim(o.r)
275+ for i := range o.p.batches {
276+ if o.p.batches[i].frame != nil {
277+ return true
278+ }
279+ }
280+ }
281+ return false
282+}
283+
284+func (o *offscreen) close() {
285+ if o.p != nil {
286+ o.p.Close(o.r)
287+ o.p = nil
288+ }
289+ if o.r != nil {
290+ o.r.Close()
291+ o.r = nil
292+ }
293+ for i := range o.slots {
294+ o.slots[i].release()
295+ }
296+ o.nf, o.w, o.h, o.session = 0, 0, 0, 0
297+}
298+
299+// childSize is the physical child image: the workspace frame at the scene
300+// scale, rounded up.
301+func childSize(cs *ports.Scene) (w, h int, err error) {
302+ scale := cs.Scale
303+ if scale <= 0 {
304+ scale = 1
305+ }
306+ fw, fh := math.Ceil(float64(cs.OutputWidth)*scale), math.Ceil(float64(cs.OutputHeight)*scale)
307+ if !(fw >= 1 && fh >= 1 && fw <= MaxOffscreenDim && fh <= MaxOffscreenDim) || fw*fh*4 > MaxOffscreenBytes {
308+ return 0, 0, fmt.Errorf("%w: frame %.0fx%.0f", ErrOffscreenGeometry, fw, fh)
309+ }
310+ return int(fw), int(fh), nil
311+}
312+
313+// track records the ceiling of a child render on the next free slot: every
314+// window and layer the render drew, with the content Seq it was given.
315+func (o *offscreen) track(done *os.File, drawn ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) {
316+ slot := &o.slots[o.nf]
317+ if slot.ceil == nil {
318+ slot.ceil = make(map[ports.WindowID]uint64, len(drawn.Windows)+len(drawn.Layers))
319+ }
320+ clear(slot.ceil)
321+ for _, w := range drawn.Windows {
322+ slot.ceil[w.ID] = surfaces[w.ID].Seq
323+ }
324+ for _, l := range drawn.Layers {
325+ slot.ceil[l.ID] = surfaces[l.ID].Seq
326+ }
327+ slot.fd = done
328+ o.nf++
329+}
330+
331+// SubmitHidden serves the clean requests of s from the child renderer, which
332+// draws s.CaptureScene without the session's excluded surfaces. It consumes
333+// every request: answered by the worker, or failed here (the display is not
334+// stopped by a child failure). Request regions are physical output pixels and
335+// must cover the whole child frame; they are rebased to its origin. The target
336+// must be the whole workspace frame too. Anything smaller needs the viewport
337+// origin in output coordinates, which the scene does not carry, and fails with
338+// ErrOffscreenGeometry.
339+func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, reqs []ports.CaptureRequest) {
340+ fail := func(err error, qs []ports.CaptureRequest) {
341+ for _, q := range qs {
342+ Fail(p.ctx, q, err, p.replies)
343+ }
344+ }
345+ o := p.off
346+ switch {
347+ case len(reqs) == 0:
348+ return
349+ case s.Off:
350+ fail(errors.New("output off"), reqs)
351+ return
352+ case s.CaptureScene == nil || s.Capture == nil:
353+ fail(ErrOffscreenUnavailable, reqs)
354+ return
355+ case o == nil || o.factory == nil:
356+ fail(ErrOffscreenUnavailable, reqs)
357+ return
358+ case p.ctx.Err() != nil:
359+ fail(p.ctx.Err(), reqs)
360+ return
361+ }
362+ cs := s.CaptureScene
363+ w, h, err := childSize(cs)
364+ if err == nil {
365+ t := s.Capture.TargetRect
366+ if t.W != cs.OutputWidth || t.H != cs.OutputHeight {
367+ err = fmt.Errorf("%w: target %dx%d is not the frame %dx%d", ErrOffscreenGeometry, t.W, t.H, cs.OutputWidth, cs.OutputHeight)
368+ }
369+ }
370+ if err != nil {
371+ fail(err, reqs)
372+ return
373+ }
374+ if o.r != nil && (o.session != s.Capture.Session || o.w != w || o.h != h) {
375+ if o.busy() {
376+ fail(ErrOffscreenBusy, reqs)
377+ return
378+ }
379+ o.close()
380+ }
381+ o.reap()
382+ if o.nf == len(o.slots) {
383+ // Refused before Render: a third frame would make the owner wait.
384+ fail(ErrOffscreenBusy, reqs)
385+ return
386+ }
387+ if o.r == nil {
388+ r, err := o.factory(w, h)
389+ if err != nil {
390+ fail(fmt.Errorf("%w: %v", ErrOffscreenUnavailable, err), reqs)
391+ return
392+ }
393+ o.r, o.p, o.w, o.h, o.session = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Session
394+ }
395+ // Request regions are physical pixels of the output; TargetRect is
396+ // relative to the workspace viewport, so it cannot place them. The child
397+ // image is the whole target (checked above): a request for all of it has
398+ // the child's size and starts at the child's origin. A smaller region
399+ // would need the viewport origin in output coordinates, which the scene
400+ // does not carry; guessing it would return the wrong crop, so it fails.
401+ k := 0
402+ for _, q := range reqs {
403+ if q.Region.Dx() != w || q.Region.Dy() != h {
404+ Fail(p.ctx, q, fmt.Errorf("%w: region %v is not the whole %dx%d workspace frame", ErrOffscreenGeometry, q.Region, w, h), p.replies)
405+ continue
406+ }
407+ q.Region = image.Rect(0, 0, w, h)
408+ reqs[k] = q
409+ k++
410+ }
411+ clear(reqs[k:])
412+ reqs = reqs[:k]
413+ if k == 0 {
414+ return
415+ }
416+ // The child never shows the session's own surfaces or border.
417+ child := *cs
418+ child.Capture = &ports.SceneCapture{Excluded: s.Capture.Excluded}
419+ child = o.clean.scene(child)
420+ done, err := o.r.Render(child, surfaces)
421+ if err != nil {
422+ fail(fmt.Errorf("render workspace: %w", err), reqs)
423+ return
424+ }
425+ if done != nil {
426+ o.track(done, child, surfaces)
427+ }
428+ o.p.Submit(o.r, reqs)
429+}
A internal/adapters/capture/offscreen_fence_test.go +294 -0
@@ -0,0 +1,294 @@
1+package capture
2+
3+import (
4+ "context"
5+ "image"
6+ "os"
7+ "testing"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+ "golang.org/x/sys/unix"
14+)
15+
16+// fencePipe is a sync file stand-in from the kernel: readable once written.
17+// fencePipe comes from pipeline_fence_test.go.
18+
19+func signalFence(t *testing.T, w *os.File) {
20+ t.Helper()
21+ _, err := w.Write([]byte{1})
22+ require.NoError(t, err)
23+}
24+
25+func fenceClosed(f *os.File) bool {
26+ _, err := f.Stat()
27+ return err != nil
28+}
29+
30+// hiddenPipeline returns a pipeline whose child renders return the given
31+// fences in order, and the scene/surfaces to submit.
32+func hiddenPipeline(t *testing.T, fences ...*os.File) (*Pipeline, chan ports.CaptureDone, *portsmocks.MockRenderer) {
33+ t.Helper()
34+ replies := make(chan ports.CaptureDone, 8)
35+ p := NewPipeline(context.Background(), replies)
36+ child := portsmocks.NewMockRenderer(t)
37+ frame := portsmocks.NewMockCaptureFrame(t)
38+ frame.EXPECT().Done().Return(nil).Maybe()
39+ frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
40+ child.EXPECT().EndCapture(frame).Return().Maybe()
41+ child.EXPECT().BeginCapture().Return(frame, nil).Maybe()
42+ child.EXPECT().Close().Return().Maybe()
43+ for _, f := range fences {
44+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(f, nil).Once()
45+ }
46+ p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
47+ return p, replies, child
48+}
49+
50+func hiddenSceneWindows(ids ...ports.WindowID) ports.Scene {
51+ s := hiddenScene()
52+ s.CaptureScene.Windows, s.CaptureScene.Layers = nil, nil
53+ for _, id := range ids {
54+ s.CaptureScene.Windows = append(s.CaptureScene.Windows, ports.SceneWindow{ID: id})
55+ }
56+ return s
57+}
58+
59+func submitHiddenOne(t *testing.T, p *Pipeline, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, id uint64) {
60+ t.Helper()
61+ p.SubmitHidden(s, surfaces, []ports.CaptureRequest{hiddenRequest(t, id, image.Rect(0, 0, 200, 100))})
62+}
63+
64+// GO001: a failing or cancelled wait still closes every fence, the ones not
65+// waited for included.
66+func TestWaitHiddenClosesEveryFenceOnCancel(t *testing.T) {
67+ f1, _ := fencePipe(t)
68+ f2, _ := fencePipe(t)
69+ p, replies, _ := hiddenPipeline(t, f1, f2)
70+ defer p.Close(nil)
71+ s := hiddenSceneWindows(5)
72+ submitHiddenOne(t, p, s, nil, 1)
73+ submitHiddenOne(t, p, s, nil, 2)
74+ require.Equal(t, 2, p.off.nf)
75+ ctx, cancel := context.WithCancel(context.Background())
76+ cancel()
77+ require.Error(t, p.WaitHidden(ctx))
78+ require.Zero(t, p.off.nf)
79+ require.True(t, fenceClosed(f1), "first fence closed")
80+ require.True(t, fenceClosed(f2), "second fence, never waited for, closed too")
81+ for range 2 {
82+ awaitCapture(t, replies)
83+ }
84+}
85+
86+// A terminal fence error must cancel and join readback before closing the
87+// device. Holds remain in place until Close has made GPU reads impossible.
88+func TestTerminalChildFenceClosesPipelineBeforeClearingHolds(t *testing.T) {
89+ read, failed, err := os.Pipe()
90+ require.NoError(t, err)
91+ defer failed.Close()
92+ pending, _ := fencePipe(t)
93+ p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 2))
94+ defer p.Close(nil)
95+ child := portsmocks.NewMockRenderer(t)
96+ frame := portsmocks.NewMockCaptureFrame(t)
97+ frame.EXPECT().Done().Return(pending).Maybe()
98+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(failed, nil).Once()
99+ child.EXPECT().BeginCapture().Return(frame, nil).Once()
100+ ended := false
101+ child.EXPECT().EndCapture(frame).Run(func(ports.CaptureFrame) { ended = true }).Once()
102+ child.EXPECT().Close().Run(func() {
103+ require.True(t, ended, "worker joined and lease returned before device closes")
104+ require.Equal(t, 1, p.off.nf, "hold retained until device closes")
105+ }).Once()
106+ p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
107+ submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{5: {Seq: 2}}, 1)
108+ _, reads, busy := p.CapHiddenSeen(map[ports.WindowID]uint64{5: 7})
109+ require.True(t, busy)
110+ require.Equal(t, map[ports.WindowID]uint64{5: 2}, reads)
111+ require.NoError(t, read.Close()) // write end now polls POLLERR
112+ p.Retire(ports.Scene{}) // session end must not wait forever on the error
113+ require.Nil(t, p.off.r, "failed device closed")
114+ require.Nil(t, p.HiddenCompleted(), "failed worker joined")
115+ seen := map[ports.WindowID]uint64{5: 7}
116+ got, reads, busy := p.CapHiddenSeen(seen)
117+ require.False(t, busy)
118+ require.Empty(t, reads, "closed device lifts non-expiring holds")
119+ require.Equal(t, seen, got)
120+ require.True(t, fenceClosed(failed))
121+}
122+
123+func TestWaitHiddenWaitsForSignalledFences(t *testing.T) {
124+ f1, w1 := fencePipe(t)
125+ p, replies, _ := hiddenPipeline(t, f1)
126+ defer p.Close(nil)
127+ submitHiddenOne(t, p, hiddenSceneWindows(5), nil, 1)
128+ signalFence(t, w1)
129+ require.NoError(t, p.WaitHidden(context.Background()))
130+ require.True(t, fenceClosed(f1))
131+ awaitCapture(t, replies)
132+}
133+
134+// GO002: the ceiling of a fence covers only the windows its render drew, per
135+// fence, and a signalled fence lifts it.
136+func TestCapHiddenSeenPerFenceCeilings(t *testing.T) {
137+ f1, w1 := fencePipe(t)
138+ f2, w2 := fencePipe(t)
139+ p, replies, _ := hiddenPipeline(t, f1, f2)
140+ defer p.Close(nil)
141+
142+ // Render 1 drew window 5 (content Seq 2); render 2 drew 5 (Seq 6) and 6 (Seq 3).
143+ submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 2}}, 1)
144+ submitHiddenOne(t, p, hiddenSceneWindows(5, 6), map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 6}, 6: {ID: 6, Seq: 3}}, 2)
145+
146+ // 5 is capped by the lowest ceiling among the unfinished renders that drew
147+ // it; 6 by the second; the display's window 1 and an unrelated 9 are free.
148+ seen := map[ports.WindowID]uint64{1: 40, 5: 8, 6: 8, 9: 50}
149+ got, reads, capped := p.CapHiddenSeen(seen)
150+ require.Equal(t, map[ports.WindowID]uint64{5: 2, 6: 3}, reads)
151+ require.True(t, capped)
152+ require.Equal(t, map[ports.WindowID]uint64{1: 40, 5: 2, 6: 3, 9: 50}, got)
153+ require.Equal(t, uint64(8), seen[5], "input untouched")
154+
155+ // Render 1 finishes: window 5 is now limited by render 2 only.
156+ signalFence(t, w1)
157+ got, reads, capped = p.CapHiddenSeen(seen)
158+ require.Equal(t, map[ports.WindowID]uint64{5: 6, 6: 3}, reads)
159+ require.True(t, capped)
160+ require.Equal(t, map[ports.WindowID]uint64{1: 40, 5: 6, 6: 3, 9: 50}, got)
161+
162+ // A window the earlier render never saw is not capped by it: a display
163+ // window that is new to the child releases at once.
164+ signalFence(t, w2)
165+ got, reads, capped = p.CapHiddenSeen(seen)
166+ require.Empty(t, reads)
167+ require.False(t, capped)
168+ require.Equal(t, seen, got)
169+ for range 2 {
170+ awaitCapture(t, replies)
171+ }
172+}
173+
174+// A window the child drew with no content yet is capped at 0; one it never
175+// drew is not capped at all.
176+func TestCapHiddenSeenMissingContentCapsToZero(t *testing.T) {
177+ f1, _ := fencePipe(t)
178+ p, replies, _ := hiddenPipeline(t, f1)
179+ defer p.Close(nil)
180+ submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{}, 1)
181+ got, reads, capped := p.CapHiddenSeen(map[ports.WindowID]uint64{5: 4, 7: 4})
182+ require.Equal(t, map[ports.WindowID]uint64{5: 0}, reads)
183+ require.True(t, capped)
184+ require.Equal(t, map[ports.WindowID]uint64{5: 0, 7: 4}, got)
185+ awaitCapture(t, replies)
186+}
187+
188+func TestCapHiddenSeenNoAllocationInSteadyState(t *testing.T) {
189+ f1, _ := fencePipe(t)
190+ p, replies, _ := hiddenPipeline(t, f1)
191+ defer p.Close(nil)
192+ submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 2}}, 1)
193+ seen := map[ports.WindowID]uint64{1: 40, 5: 8}
194+ p.CapHiddenSeen(seen) // warm the scratch map
195+ allocs := testing.AllocsPerRun(100, func() { p.CapHiddenSeen(seen) })
196+ require.LessOrEqual(t, allocs, float64(1), "only the poll descriptor may allocate")
197+ t.Logf("outstanding child report: %.1f allocs", allocs)
198+ awaitCapture(t, replies)
199+ var none *Pipeline
200+ got, reads, capped := none.CapHiddenSeen(seen)
201+ require.Nil(t, reads)
202+ require.False(t, capped)
203+ require.Equal(t, seen, got)
204+}
205+
206+// GO003: a third render is refused before the renderer is asked to draw it.
207+func TestSubmitHiddenThirdRenderRefusedBeforeRender(t *testing.T) {
208+ f1, _ := fencePipe(t)
209+ f2, _ := fencePipe(t)
210+ p, replies, _ := hiddenPipeline(t, f1, f2) // only two Render expectations
211+ defer p.Close(nil)
212+ s := hiddenSceneWindows(5)
213+ submitHiddenOne(t, p, s, nil, 1)
214+ submitHiddenOne(t, p, s, nil, 2)
215+ submitHiddenOne(t, p, s, nil, 3)
216+ byID := map[uint64]error{}
217+ for range 3 {
218+ d := awaitCapture(t, replies)
219+ byID[d.ID] = d.Err
220+ }
221+ require.NoError(t, byID[1])
222+ require.NoError(t, byID[2])
223+ require.ErrorIs(t, byID[3], ErrOffscreenBusy)
224+}
225+
226+// GO005: an idle child of an ended session is closed once, whether its lease
227+// or its fence came back last.
228+func TestRetireClosesIdleChildOnce(t *testing.T) {
229+ f1, w1 := fencePipe(t)
230+ replies := make(chan ports.CaptureDone, 4)
231+ p := NewPipeline(context.Background(), replies)
232+ child := portsmocks.NewMockRenderer(t)
233+ frame := portsmocks.NewMockCaptureFrame(t)
234+ frame.EXPECT().Done().Return(nil).Maybe()
235+ frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
236+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(f1, nil).Once()
237+ child.EXPECT().BeginCapture().Return(frame, nil).Once()
238+ child.EXPECT().EndCapture(frame).Return().Once()
239+ child.EXPECT().Close().Return().Once() // exactly once
240+ p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
241+
242+ submitHiddenOne(t, p, hiddenSceneWindows(5), nil, 1)
243+ awaitCapture(t, replies)
244+ p.RecycleHidden(<-p.HiddenCompleted())
245+
246+ // The fence is still pending: the child stays.
247+ p.Retire(ports.Scene{})
248+ require.NotNil(t, p.off.r)
249+ signalFence(t, w1)
250+ p.Retire(ports.Scene{})
251+ require.Nil(t, p.off.r, "closed once idle")
252+ require.Nil(t, p.HiddenCompleted(), "no child, no channel")
253+ p.Retire(ports.Scene{}) // nothing left to close
254+ p.Close(nil)
255+}
256+
257+// GO006: a readable fence (or an invalid descriptor) is done; POLLERR and poll
258+// failures are terminal; timeouts and interrupts stay pending.
259+func TestPollResult(t *testing.T) {
260+ for _, tc := range []struct {
261+ name string
262+ n int
263+ err error
264+ revents int16
265+ want fenceState
266+ }{
267+ {"readable", 1, nil, unix.POLLIN, fenceDone},
268+ {"timeout", 0, nil, 0, fencePending},
269+ {"interrupted", -1, unix.EINTR, 0, fencePending},
270+ {"other poll error", -1, unix.EIO, 0, fenceFailed},
271+ {"invalid poll descriptor", -1, unix.EBADF, 0, fenceDone},
272+ {"invalid descriptor event", 1, nil, unix.POLLNVAL, fenceDone},
273+ {"gpu error status", 1, nil, unix.POLLERR, fenceFailed},
274+ {"error with data", 1, nil, unix.POLLIN | unix.POLLERR, fenceFailed},
275+ {"hangup only", 1, nil, unix.POLLHUP, fencePending},
276+ } {
277+ t.Run(tc.name, func(t *testing.T) {
278+ require.Equal(t, tc.want, pollResult(tc.n, tc.err, tc.revents))
279+ })
280+ }
281+}
282+
283+func TestPollFenceOnRealDescriptors(t *testing.T) {
284+ r, w := fencePipe(t)
285+ require.Equal(t, fencePending, pollFence(r), "nothing written yet")
286+ signalFence(t, w)
287+ require.Equal(t, fenceDone, pollFence(r))
288+ closed, _ := fencePipe(t)
289+ require.NoError(t, closed.Close())
290+ require.Equal(t, fenceDone, pollFence(closed), "a closed descriptor has nothing to wait for")
291+ allocs := testing.AllocsPerRun(100, func() { pollFence(r) })
292+ require.Zero(t, allocs, "polling a fence does not allocate")
293+ t.Logf("pollFence: %.1f allocs", allocs)
294+}
A internal/adapters/capture/offscreen_test.go +149 -0
@@ -0,0 +1,149 @@
1+package capture
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "os"
8+ "testing"
9+
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func hiddenScene() ports.Scene {
17+ return ports.Scene{
18+ Scale: 2, OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{{ID: 1}},
19+ Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 100, H: 50}, Revision: 1, Excluded: []ports.WindowID{10}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor},
20+ CaptureScene: &ports.Scene{
21+ OutputWidth: 100, OutputHeight: 50, Scale: 2, Seq: 9,
22+ Windows: []ports.SceneWindow{{ID: 5}, {ID: 10, Popup: true}},
23+ Layers: []ports.SceneLayer{{ID: 10}, {ID: 11}},
24+ },
25+ }
26+}
27+
28+func hiddenRequest(t *testing.T, id uint64, region image.Rectangle) ports.CaptureRequest {
29+ q := pipelineRequest(t, id)
30+ q.Clean, q.Session, q.CaptureRevision = true, 7, 1
31+ q.Region, q.Width, q.Height, q.Stride = region, region.Dx(), region.Dy(), region.Dx()*4
32+ require.NoError(t, q.Dst.File.Truncate(int64(q.Stride*q.Height)))
33+ return q
34+}
35+
36+func childMock(t *testing.T, w, h int) (*portsmocks.MockRenderer, *portsmocks.MockCaptureFrame) {
37+ r := portsmocks.NewMockRenderer(t)
38+ frame := portsmocks.NewMockCaptureFrame(t)
39+ frame.EXPECT().Done().Return(nil).Maybe()
40+ frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
41+ r.EXPECT().EndCapture(frame).Return().Maybe()
42+ r.EXPECT().Close().Return().Once()
43+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
44+ return r, frame
45+}
46+
47+func TestSubmitHiddenDrawsChildWithoutExcludedAndRebasesRegion(t *testing.T) {
48+ replies := make(chan ports.CaptureDone, 4)
49+ p := NewPipeline(context.Background(), replies)
50+ child, _ := childMock(t, 200, 100)
51+ var scene ports.Scene
52+ child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
53+ scene = s
54+ return nil, nil
55+ }).Once()
56+ var made [2]int
57+ p.EnableOffscreen(func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil })
58+ s := hiddenScene()
59+ s.Capture.TargetRect = ports.Rect{W: 100, H: 50}
60+ p.SubmitHidden(s, nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(480, 360, 680, 460))})
61+ require.Equal(t, [2]int{200, 100}, made, "child image is the frame in physical pixels")
62+ require.Nil(t, scene.Capture)
63+ require.Zero(t, scene.Seq)
64+ require.Equal(t, []ports.SceneWindow{{ID: 5}}, scene.Windows, "excluded popup left out")
65+ require.Equal(t, []ports.SceneLayer{{ID: 11}}, scene.Layers, "excluded HUD layer left out")
66+ require.NoError(t, awaitCapture(t, replies).Err)
67+ p.Close(nil)
68+}
69+
70+func TestSubmitHiddenFailsClosed(t *testing.T) {
71+ for _, tc := range []struct {
72+ name string
73+ edit func(*ports.Scene)
74+ want error
75+ }{
76+ {"output off", func(s *ports.Scene) { s.Off = true }, nil},
77+ {"target is not the frame", func(s *ports.Scene) { s.Capture.TargetRect.W = 50 }, ErrOffscreenGeometry},
78+ {"frame too large", func(s *ports.Scene) { s.CaptureScene.OutputWidth, s.Capture.TargetRect.W = 20000, 20000 }, ErrOffscreenGeometry},
79+ {"frame empty", func(s *ports.Scene) { s.CaptureScene.OutputWidth, s.Capture.TargetRect.W = 0, 0 }, ErrOffscreenGeometry},
80+ } {
81+ t.Run(tc.name, func(t *testing.T) {
82+ replies := make(chan ports.CaptureDone, 2)
83+ p := NewPipeline(context.Background(), replies)
84+ defer p.Close(nil)
85+ p.EnableOffscreen(func(int, int) (ports.Renderer, error) { t.Fatal("factory called"); return nil, nil })
86+ s := hiddenScene()
87+ tc.edit(&s)
88+ p.SubmitHidden(s, nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))})
89+ got := awaitCapture(t, replies)
90+ require.Error(t, got.Err)
91+ if tc.want != nil {
92+ require.ErrorIs(t, got.Err, tc.want)
93+ }
94+ })
95+ }
96+}
97+
98+func TestSubmitHiddenFactoryFailureAndNoFactory(t *testing.T) {
99+ replies := make(chan ports.CaptureDone, 4)
100+ p := NewPipeline(context.Background(), replies)
101+ defer p.Close(nil)
102+ p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))})
103+ require.ErrorIs(t, awaitCapture(t, replies).Err, ErrOffscreenUnavailable)
104+ p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return nil, errors.New("no gpu") })
105+ p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 2, image.Rect(0, 0, 200, 100))})
106+ require.ErrorIs(t, awaitCapture(t, replies).Err, ErrOffscreenUnavailable)
107+}
108+
109+func TestSubmitHiddenRegionOutsideFrameFailsOthersServed(t *testing.T) {
110+ replies := make(chan ports.CaptureDone, 4)
111+ p := NewPipeline(context.Background(), replies)
112+ child, _ := childMock(t, 200, 100)
113+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
114+ p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
115+ p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{
116+ hiddenRequest(t, 1, image.Rect(0, 0, 300, 100)), // not the child's size
117+ hiddenRequest(t, 2, image.Rect(0, 0, 200, 100)),
118+ })
119+ byID := map[uint64]error{}
120+ for range 2 {
121+ d := awaitCapture(t, replies)
122+ byID[d.ID] = d.Err
123+ }
124+ require.ErrorIs(t, byID[1], ErrOffscreenGeometry)
125+ require.NoError(t, byID[2])
126+ p.Close(nil)
127+}
128+
129+func TestSubmitHiddenChildClosesWhenSessionEnds(t *testing.T) {
130+ replies := make(chan ports.CaptureDone, 4)
131+ p := NewPipeline(context.Background(), replies)
132+ child, _ := childMock(t, 200, 100)
133+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
134+ p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
135+ p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))})
136+ require.NoError(t, awaitCapture(t, replies).Err)
137+ // Wait for the worker to hand the lease back, then the session ends.
138+ b := <-p.HiddenCompleted()
139+ p.RecycleHidden(b)
140+ p.Retire(ports.Scene{})
141+ require.Nil(t, p.off.r, "child closed once the scene has no CaptureScene")
142+ p.Close(nil)
143+}
144+
145+func TestHiddenNilPipelineSafe(t *testing.T) {
146+ var p *Pipeline
147+ require.False(t, p.HiddenReading())
148+ p.Retire(ports.Scene{})
149+}
M internal/adapters/capture/pipeline.go +5 -0
@@ -33,6 +33,8 @@ type Pipeline struct {
3333 jobs chan *batch
3434 completed chan *batch
3535 batches [pipelineSlots]batch
36+ clean cleaner // scratch of CleanScene, owner only
37+ off *offscreen
3638 worker sync.WaitGroup
3739 }
3840
@@ -146,4 +148,7 @@ func (p *Pipeline) Close(r ports.Renderer) {
146148 p.cancel()
147149 p.worker.Wait()
148150 p.reclaim(r)
151+ if p.off != nil {
152+ p.off.close()
153+ }
149154 }
A internal/adapters/capture/shows_test.go +29 -0
@@ -0,0 +1,29 @@
1+package capture
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// A content change of a window only the hidden workspace draws needs a frame
11+// (its child render and the reads it releases); the displayed scene alone
12+// would say no.
13+func TestShowsCountsHiddenWorkspace(t *testing.T) {
14+ s := ports.Scene{
15+ OutputWidth: 100, OutputHeight: 50,
16+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 50}}},
17+ CaptureScene: &ports.Scene{
18+ OutputWidth: 100, OutputHeight: 50,
19+ Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 100, H: 50}}},
20+ },
21+ }
22+ require.True(t, Shows(s, 1), "displayed window")
23+ require.True(t, Shows(s, 5), "hidden workspace window")
24+ require.False(t, s.Shows(5), "the displayed scene alone does not show it")
25+ require.False(t, Shows(s, 9), "unrelated window")
26+ s.CaptureScene = nil
27+ require.False(t, Shows(s, 5), "no hidden workspace, no frame")
28+ require.True(t, Shows(s, 1))
29+}
A internal/adapters/drm/capture_session_test.go +394 -0
@@ -0,0 +1,394 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferwl/internal/adapters/capture"
12+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
13+ "github.com/bnema/neferwl/internal/ports"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func sessionRequest(t *testing.T, id uint64, clean bool) ports.CaptureRequest {
19+ t.Helper()
20+ f, err := os.CreateTemp(t.TempDir(), "capture")
21+ require.NoError(t, err)
22+ t.Cleanup(func() { _ = f.Close() })
23+ require.NoError(t, f.Truncate(16))
24+ q := ports.CaptureRequest{ID: id, Dst: ports.SHMBuffer{File: f}, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1}
25+ if clean {
26+ q.Clean, q.Session = true, 7
27+ }
28+ return q
29+}
30+
31+func drmSessionScene() ports.Scene {
32+ return ports.Scene{Seq: 4, Windows: []ports.SceneWindow{{ID: 1}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}},
33+ Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, Excluded: []ports.WindowID{10}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}}
34+}
35+
36+// The clean frame is drawn and copied before the displayed frame, on the
37+// same target; the standard capture is taken after the displayed frame.
38+func TestSubmitFrameCleanBeforeDisplayedThenStandard(t *testing.T) {
39+ o, _, _ := testOutput(t)
40+ o.cursor = nil
41+ var order []string
42+ r := portsmocks.NewMockRenderer(t)
43+ r.EXPECT().UseTarget(0).Return().Once()
44+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
45+ if s.Capture == nil {
46+ require.Zero(t, s.Seq)
47+ require.Len(t, s.Layers, 0, "excluded HUD layer left out of the clean frame")
48+ order = append(order, "clean")
49+ } else {
50+ require.Len(t, s.Layers, 1, "displayed frame keeps the HUD")
51+ order = append(order, "shown")
52+ }
53+ return nil, nil
54+ }).Twice()
55+ frame := portsmocks.NewMockCaptureFrame(t)
56+ frame.EXPECT().Done().Return(nil).Maybe()
57+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
58+ r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) {
59+ order = append(order, "copy")
60+ return frame, nil
61+ }).Twice()
62+ r.EXPECT().EndCapture(frame).Return().Twice()
63+ replies := make(chan ports.CaptureDone, 4)
64+ pipeline := capture.NewPipeline(context.Background(), replies)
65+ requests := []ports.CaptureRequest{sessionRequest(t, 1, true), sessionRequest(t, 2, false)}
66+ direct, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, requests, pipeline)
67+ require.NoError(t, err)
68+ require.False(t, direct)
69+ require.Equal(t, []string{"clean", "copy", "shown", "copy"}, order)
70+ for _, q := range requests {
71+ require.True(t, capture.Handed(q), "requests handed to the worker")
72+ }
73+ pipeline.Close(r)
74+ require.Len(t, replies, 2)
75+}
76+
77+// A fullscreen window would be scanned out (no border): a session forces
78+// composition even when no capture is requested this frame.
79+func TestSessionScenePreventsDirectScanoutDecision(t *testing.T) {
80+ o, _, _ := testOutput(t)
81+ o.cursor = nil
82+ r := portsmocks.NewMockRenderer(t)
83+ r.EXPECT().UseTarget(0).Return().Once()
84+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
85+ pipeline := capture.NewPipeline(context.Background(), nil)
86+ defer pipeline.Close(r)
87+ direct, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, nil, pipeline)
88+ require.NoError(t, err)
89+ require.False(t, direct)
90+ require.Equal(t, "capture", o.reason)
91+}
92+
93+// A full render failing after the clean group was handed off: the caller
94+// still owns only the standard request; the clean one is zeroed, so it is
95+// never answered twice.
96+func TestSubmitFrameRenderErrorKeepsOnlyUnhandedRequests(t *testing.T) {
97+ o, _, _ := testOutput(t)
98+ o.cursor = nil
99+ boom := errors.New("boom")
100+ r := portsmocks.NewMockRenderer(t)
101+ r.EXPECT().UseTarget(0).Return().Once()
102+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
103+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, boom).Once()
104+ frame := portsmocks.NewMockCaptureFrame(t)
105+ frame.EXPECT().Done().Return(nil).Maybe()
106+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
107+ r.EXPECT().BeginCapture().Return(frame, nil).Once()
108+ r.EXPECT().EndCapture(frame).Return().Once()
109+ replies := make(chan ports.CaptureDone, 4)
110+ pipeline := capture.NewPipeline(context.Background(), replies)
111+ requests := []ports.CaptureRequest{sessionRequest(t, 1, true), sessionRequest(t, 2, false)}
112+ _, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, requests, pipeline)
113+ var fatal renderError
114+ require.ErrorAs(t, err, &fatal)
115+ owned := 0
116+ for _, q := range requests {
117+ if !capture.Handed(q) {
118+ owned++
119+ require.Equal(t, uint64(2), q.ID)
120+ }
121+ }
122+ require.Equal(t, 1, owned)
123+ pipeline.Close(r)
124+ require.Len(t, replies, 1) // the clean capture, answered once by the worker
125+}
126+
127+// A clean request of a session the scene no longer carries fails closed
128+// and is never captured from the displayed frame.
129+func TestSubmitFrameCleanRequestWithoutSessionFails(t *testing.T) {
130+ o, _, _ := testOutput(t)
131+ o.cursor = nil
132+ r := portsmocks.NewMockRenderer(t)
133+ r.EXPECT().UseTarget(0).Return().Once()
134+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
135+ replies := make(chan ports.CaptureDone, 2)
136+ pipeline := capture.NewPipeline(context.Background(), replies)
137+ defer pipeline.Close(r)
138+ requests := []ports.CaptureRequest{sessionRequest(t, 1, true)}
139+ scene := drmSessionScene()
140+ scene.Capture = nil
141+ _, err := o.submitFrame(context.Background(), r, scene, nil, nil, requests, pipeline)
142+ require.NoError(t, err)
143+ got := <-replies
144+ require.ErrorIs(t, got.Err, capture.ErrSessionInactive)
145+ require.True(t, capture.Handed(requests[0]))
146+}
147+
148+// A session with no visible border (and no HUD in the scene) must not cost
149+// the fullscreen window its direct scanout; a visible border must.
150+func TestSessionMetadataWithoutBorderKeepsDirectScanout(t *testing.T) {
151+ for _, tc := range []struct {
152+ name string
153+ capture *ports.SceneCapture
154+ wantDirect bool
155+ }{
156+ {"no session", nil, true},
157+ {"session, no border", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, Excluded: []ports.WindowID{10}}, true},
158+ {"session, zero width", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, BorderColor: ports.CaptureBorderColor}, true},
159+ {"session, border wider than the target allows", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 1, H: 100}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}, true},
160+ {"session, visible border", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}, false},
161+ } {
162+ t.Run(tc.name, func(t *testing.T) {
163+ o, k, _ := testOutput(t)
164+ o.cursor, o.tearing, o.vrrProp = nil, false, 0
165+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
166+ r := portsmocks.NewMockRenderer(t)
167+ s, c := fullscreenScene()
168+ s.Capture = tc.capture
169+ if tc.wantDirect {
170+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
171+ } else {
172+ r.EXPECT().UseTarget(0).Return().Once()
173+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
174+ }
175+ pipeline := capture.NewPipeline(context.Background(), nil)
176+ defer pipeline.Close(r)
177+ direct, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, pipeline)
178+ require.NoError(t, err)
179+ require.Equal(t, tc.wantDirect, direct)
180+ })
181+ }
182+}
183+
184+// A clean request for a hidden workspace with no child renderer factory
185+// fails closed; it is neither served from the displayed frame nor does it
186+// stop the output.
187+func TestSubmitFrameHiddenWorkspaceWithoutFactoryFailsClosed(t *testing.T) {
188+ o, _, _ := testOutput(t)
189+ o.cursor = nil
190+ r := portsmocks.NewMockRenderer(t)
191+ r.EXPECT().UseTarget(0).Return().Once()
192+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() // the display only
193+ replies := make(chan ports.CaptureDone, 2)
194+ pipeline := capture.NewPipeline(context.Background(), replies)
195+ defer pipeline.Close(r)
196+ scene := drmSessionScene()
197+ scene.CaptureScene = &ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
198+ requests := []ports.CaptureRequest{sessionRequest(t, 1, true)}
199+ _, err := o.submitFrame(context.Background(), r, scene, nil, nil, requests, pipeline)
200+ require.NoError(t, err)
201+ require.ErrorIs(t, (<-replies).Err, capture.ErrOffscreenUnavailable)
202+}
203+
204+// A report caps only the windows an unfinished child render drew; the display's
205+// own windows are reported at their full content Seq, and the report goes on
206+// (nothing waits for the child).
207+func TestReportCapsPerWindowWhileChildReads(t *testing.T) {
208+ o, _, _ := testOutput(t)
209+ readEnd, writeEnd, err := os.Pipe() // the child's sync file: readable once it signals
210+ require.NoError(t, err)
211+ t.Cleanup(func() { _ = writeEnd.Close() })
212+ child := portsmocks.NewMockRenderer(t)
213+ frame := portsmocks.NewMockCaptureFrame(t)
214+ frame.EXPECT().Done().Return(nil).Maybe()
215+ frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
216+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(readEnd, nil).Once()
217+ child.EXPECT().BeginCapture().Return(frame, nil).Once()
218+ child.EXPECT().EndCapture(frame).Return().Maybe()
219+ child.EXPECT().Close().Return().Once()
220+ replies := make(chan ports.CaptureDone, 2)
221+ pipeline := capture.NewPipeline(context.Background(), replies)
222+ pipeline.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
223+ o.capHidden = pipeline.CapHiddenSeen
224+ scene := ports.Scene{Scale: 1, OutputWidth: 2, OutputHeight: 2,
225+ Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 2, H: 2}},
226+ CaptureScene: &ports.Scene{Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 2, H: 2}}}}}
227+ surfaces := map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 2}}
228+ req := sessionRequest(t, 1, true)
229+ req.Region, req.Width, req.Height = image.Rect(0, 0, 2, 2), 2, 2
230+ pipeline.SubmitHidden(scene, surfaces, []ports.CaptureRequest{req})
231+
232+ seen := map[ports.WindowID]uint64{1: 9, 5: 7} // window 1 belongs to the display
233+ o.report(nil, seen)
234+ require.True(t, o.capped, "an outstanding child read is reported repeatedly")
235+ require.Equal(t, map[ports.WindowID]uint64{1: 9, 5: 2}, o.unsent[0].Seen)
236+ require.Equal(t, uint64(7), seen[5], "the caller's map is untouched")
237+ require.Equal(t, map[ports.WindowID]uint64{5: 2}, o.unsent[0].ChildReads)
238+ previous := o.unsent[0].ChildReads
239+ allocs := testing.AllocsPerRun(100, func() { o.report(nil, seen) })
240+ require.LessOrEqual(t, allocs, float64(1), "only the fence poll may allocate")
241+ t.Logf("unchanged child report: %.1f allocs", allocs)
242+
243+ _, err = writeEnd.Write([]byte{1}) // the child's GPU work finished
244+ require.NoError(t, err)
245+ o.report(nil, seen)
246+ require.False(t, o.capped)
247+ require.Equal(t, map[ports.WindowID]uint64{1: 9, 5: 7}, o.unsent[0].Seen, "limit lifted")
248+ require.Empty(t, o.unsent[0].ChildReads)
249+ require.Equal(t, map[ports.WindowID]uint64{5: 2}, previous, "sent snapshot remains immutable")
250+ pipeline.Close(nil)
251+}
252+
253+// GO004: a hidden-workspace child is drawn on its own device: the display
254+// keeps direct scanout of a fullscreen window when no capture reads the
255+// displayed frame and the session draws no border on screen.
256+func TestHiddenWorkspaceChildKeepsDirectScanout(t *testing.T) {
257+ o, k, _ := testOutput(t)
258+ o.cursor, o.tearing, o.vrrProp = nil, false, 0
259+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
260+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
261+ readEnd, writeEnd, err := os.Pipe()
262+ require.NoError(t, err)
263+ t.Cleanup(func() { _ = writeEnd.Close() })
264+ child := portsmocks.NewMockRenderer(t)
265+ frame := portsmocks.NewMockCaptureFrame(t)
266+ frame.EXPECT().Done().Return(nil).Maybe()
267+ frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
268+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(readEnd, nil).Once()
269+ child.EXPECT().BeginCapture().Return(frame, nil).Once()
270+ child.EXPECT().EndCapture(frame).Return().Maybe()
271+ child.EXPECT().Close().Return().Once()
272+ display := portsmocks.NewMockRenderer(t) // no Render: the display is scanned out
273+ replies := make(chan ports.CaptureDone, 2)
274+ pipeline := capture.NewPipeline(context.Background(), replies)
275+ pipeline.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
276+ o.capHidden = pipeline.CapHiddenSeen
277+ s, c := fullscreenScene()
278+ s.Capture = &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, Revision: 1, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}
279+ s.CaptureScene = &ports.Scene{Scale: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 200, H: 100}}}}
280+ // Hidden workspace: core sends the on-screen border off (BorderWidth 0).
281+ s.Capture.BorderWidth, s.Capture.BorderColor = 0, ""
282+ req := sessionRequest(t, 1, true)
283+ req.CaptureRevision = 1
284+ req.Region, req.Width, req.Height, req.Stride = image.Rect(0, 0, 200, 100), 200, 100, 800
285+ require.NoError(t, req.Dst.File.Truncate(800*100))
286+ direct, err := o.submitFrame(context.Background(), display, s, c, map[ports.WindowID]uint64{}, []ports.CaptureRequest{req}, pipeline)
287+ require.NoError(t, err)
288+ require.True(t, direct, "the displayed fullscreen window stays scanned out")
289+ require.True(t, o.frame.pendingCommit(), "display flip has not completed")
290+ require.Equal(t, map[ports.WindowID]uint64{5: 0}, o.unsent[0].ChildReads, "child hold published before the display flip")
291+ require.Empty(t, o.unsent[0].Seen, "display reads are not advanced early")
292+ require.NoError(t, (<-replies).Err)
293+ _, err = writeEnd.Write([]byte{1})
294+ require.NoError(t, err)
295+ pipeline.Close(display)
296+}
297+
298+// GO-002: child holds do not expire, so a finished child read must lift its
299+// hold even while the display's own commit never completes. The display's
300+// Seen stays at the last safe value and the output is not stopped.
301+func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) {
302+ o, k, commits, commitMu := testOutputMu(t, nil, nil)
303+ o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
304+ o.frame.stuckAfter = 30 * time.Second // the display commit stays in flight
305+ o.flipped = make(chan flipEvent) // no flip event ever
306+ display := portsmocks.NewMockRenderer(t)
307+ buf := func() ports.DMABuf {
308+ f, w, _ := os.Pipe()
309+ w.Close()
310+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
311+ }
312+ display.EXPECT().SetHDR(float64(0)).Return().Maybe()
313+ display.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
314+ display.EXPECT().UseTarget(mock.Anything).Return()
315+ display.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
316+ display.EXPECT().Close().Return().Once()
317+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
318+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
319+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
320+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
321+
322+ readEnd, writeEnd, err := os.Pipe()
323+ require.NoError(t, err)
324+ t.Cleanup(func() { _ = writeEnd.Close() })
325+ child := portsmocks.NewMockRenderer(t)
326+ frame := portsmocks.NewMockCaptureFrame(t)
327+ frame.EXPECT().Done().Return(nil).Maybe()
328+ frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
329+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(readEnd, nil).Once()
330+ child.EXPECT().BeginCapture().Return(frame, nil).Once()
331+ child.EXPECT().EndCapture(frame).Return().Maybe()
332+ child.EXPECT().Close().Return().Once()
333+ o.NewCaptureRenderer = func(int, int) (ports.Renderer, error) { return child, nil }
334+
335+ scenes := make(chan ports.Scene)
336+ contents := make(chan ports.SurfaceContent) // unbuffered: taken before the scene
337+ captures := make(chan ports.CaptureRequest) // unbuffered: a send returns once Run took it
338+ captured := make(chan ports.CaptureDone, 4)
339+ presented := make(chan ports.OutputPresented, 64)
340+ ctx, cancel := context.WithCancel(context.Background())
341+ done := make(chan error, 1)
342+ go func() {
343+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return display, nil }, nil, make(chan bool), scenes, contents, nil, presented, captures, captured)
344+ }()
345+ count := func() int {
346+ commitMu.Lock()
347+ defer commitMu.Unlock()
348+ return len(*commits)
349+ }
350+ waitFor(t, func() bool { return count() == 2 })
351+
352+ contents <- ports.SurfaceContent{ID: 5, Seq: 2, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}}
353+ // The request waits for the scene; once a display frame is pending no
354+ // request is served, so it must be queued before the scene arrives.
355+ req := sessionRequest(t, 1, true)
356+ req.Region, req.Width, req.Height, req.Stride = image.Rect(0, 0, 200, 100), 200, 100, 800
357+ require.NoError(t, req.Dst.File.Truncate(800*100))
358+ captures <- req
359+ scenes <- ports.Scene{Seq: 1, Scale: 1, OutputWidth: 200, OutputHeight: 100,
360+ Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}},
361+ CaptureScene: &ports.Scene{Scale: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 200, H: 100}}}}}
362+
363+ next := func(match func(ports.OutputPresented) bool) ports.OutputPresented {
364+ t.Helper()
365+ timeout := time.After(2 * time.Second)
366+ for {
367+ select {
368+ case r := <-presented:
369+ if match(r) {
370+ return r
371+ }
372+ case <-timeout:
373+ t.Fatal("no matching report")
374+ }
375+ }
376+ }
377+ held := next(func(r ports.OutputPresented) bool { return len(r.ChildReads) > 0 })
378+ require.Equal(t, map[ports.WindowID]uint64{5: 2}, held.ChildReads)
379+ require.Equal(t, 3, count(), "display frame committed, never completes")
380+
381+ // A newer buffer of a display window arrives while the display commit is
382+ // pending: the GPU may still read the older one, so it is not reported.
383+ contents <- ports.SurfaceContent{ID: 1, Seq: 9, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}}
384+ _, err = writeEnd.Write([]byte{1}) // the child's GPU work finished
385+ require.NoError(t, err)
386+ lifted := next(func(r ports.OutputPresented) bool { return len(r.ChildReads) == 0 })
387+ require.Empty(t, lifted.ChildReads)
388+ require.NotContains(t, lifted.Seen, ports.WindowID(1), "display Seen does not advance while its commit is pending: only the child hold is lifted")
389+ require.Nil(t, lifted.Flip)
390+ require.Equal(t, 3, count(), "no extra commit")
391+ require.NoError(t, (<-captured).Err)
392+ cancel()
393+ require.NoError(t, <-done)
394+}
M internal/adapters/drm/frame_submit.go +42 -5
@@ -27,7 +27,29 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
2727 // windows it does not draw is discarded, not presented.
2828 f := pendingFrame{shows: o.shownBy(scene, seen)}
2929 o.wantContent(scene, surfaces)
30- decision := o.decideFrame(scene, surfaces, len(requests) > 0)
30+ // Clean requests that cannot be served (their session is not the
31+ // scene's) are failed here, never answered from the displayed frame.
32+ normal, clean := pipeline.Split(scene, requests)
33+ pipeline.Retire(scene)
34+ hiddenClean := len(clean) > 0 && scene.CaptureScene != nil
35+ if hiddenClean {
36+ // A hidden workspace: the child renderer draws it, whatever the display
37+ // does (scanout included). Failures answer the requests and never stop
38+ // the output.
39+ pipeline.SubmitHidden(scene, surfaces, clean)
40+ // Publish child holds before queuing a display flip, which may stall
41+ // past the stale-report timeout. Do not advance the display's Seen
42+ // until its own GPU work finishes.
43+ if o.capHidden != nil {
44+ o.report(nil, o.seenSnapshot)
45+ }
46+ clear(clean)
47+ clean = nil
48+ }
49+ // Captures of the displayed frame, and a visible native session border,
50+ // force composition: scanout and the overlay plane would skip them. The
51+ // hidden-workspace child needs none, so it keeps direct scanout.
52+ decision := o.decideFrame(scene, surfaces, len(normal) > 0 || len(clean) > 0 || capture.BorderVisible(scene))
3153 if decision.fb != 0 {
3254 c := decision.content
3355 if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct {
@@ -37,6 +59,21 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
3759 }
3860 ov, composed := decision.overlay, decision.composed
3961 r.UseTarget(o.back)
62+ if len(clean) > 0 {
63+ // The clean frame goes first to the same target and is copied by
64+ // the capture; the displayed frame is drawn over it in queue order.
65+ // Cost: a second composition on frames with a clean request.
66+ cdone, cerr := r.Render(pipeline.CleanScene(composed), surfaces)
67+ if cerr != nil {
68+ return false, renderError{cerr}
69+ }
70+ if cdone != nil {
71+ cdone.Close()
72+ }
73+ pipeline.Submit(r, clean)
74+ // Ownership moved to the worker (or an immediate failure reply).
75+ clear(clean)
76+ }
4077 done, rerr := r.Render(composed, surfaces)
4178 if rerr != nil {
4279 ov.close()
@@ -53,13 +90,13 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
5390 return false, renderError{rerr}
5491 }
5592 }
56- if len(requests) > 0 {
93+ if len(normal) > 0 {
5794 // Scanout and the overlay plane were skipped for this frame.
58- o.log.Debug().Str("connector", o.conn.name).Int("captures", len(requests)).Msg("capture frame composed")
59- pipeline.Submit(r, requests)
95+ o.log.Debug().Str("connector", o.conn.name).Int("captures", len(normal)).Msg("capture frame composed")
96+ pipeline.Submit(r, normal)
6097 // Ownership moved to the worker (or an immediate failure reply).
6198 // A later commit panic must not fail the same requests again.
62- clear(requests)
99+ clear(normal)
63100 }
64101 // The overlay buffer is on screen like a scanned-out one: it is
65102 // reported shown, so it is not released under the plane.
M internal/adapters/drm/output.go +49 -6
@@ -66,6 +66,16 @@ type Output struct {
6666 back int
6767 frame frameLifecycle // pending commit, serials, deadlines and timer
6868 flips int
69+ // NewCaptureRenderer makes the child renderer of a hidden workspace
70+ // capture session (Scene.CaptureScene). Nil: such captures fail closed.
71+ // Set before Run.
72+ NewCaptureRenderer func(w, h int) (ports.Renderer, error)
73+ // capHidden limits a report to what the child renderer of a hidden
74+ // workspace was given, window by window, while it may still read (its
75+ // device is not ordered with the display's fences). capped is set when
76+ // the last report had child reads: it is repeated until they finish.
77+ capHidden func(map[ports.WindowID]uint64) (map[ports.WindowID]uint64, map[ports.WindowID]uint64, bool)
78+ capped bool
6979 // readFences are fences of frames rendered but not committed: the
7080 // GPU may still read client buffers until they signal, so what was
7181 // seen is reported only then (or after a later frame flipped).
@@ -81,6 +91,7 @@ type Output struct {
8191 // Last immutable snapshot sent in a report. Never mutate it: wayland may
8292 // still be reading a prior report on another goroutine.
8393 seenSnapshot map[ports.WindowID]uint64
94+ childReadsSnapshot map[ports.WindowID]uint64
8495 showsSnapshot map[ports.WindowID]uint64
8596 directShowsSnapshot map[ports.WindowID]uint64
8697 showsScratch map[ports.WindowID]uint64
@@ -981,6 +992,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
981992 requests := requestStorage[:0]
982993 ctx, cancelCaptures := context.WithCancel(ctx)
983994 pipeline := capture.NewPipeline(ctx, captured)
995+ if o.NewCaptureRenderer != nil {
996+ pipeline.EnableOffscreen(o.NewCaptureRenderer)
997+ o.capHidden = pipeline.CapHiddenSeen
998+ }
984999 defer func() {
9851000 cancelCaptures()
9861001 pipeline.Close(r)
@@ -1031,10 +1046,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10311046 }
10321047 for {
10331048 readWait := false
1034- if reportDirty && !o.frame.pendingCommit() {
1035- if readWait = !o.readDone(); !readWait {
1036- o.report(nil, seen)
1037- reportDirty = false
1049+ if reportDirty || o.capped {
1050+ published := false
1051+ if !o.frame.pendingCommit() {
1052+ if readWait = !o.readDone(); !readWait {
1053+ o.report(nil, seen)
1054+ reportDirty, published = false, true
1055+ // A report limited by the child's reads is repeated soon.
1056+ readWait = o.capped
1057+ }
1058+ }
1059+ if !published && o.capped {
1060+ // A display frame or fence that has not finished must not
1061+ // keep a finished child read held: child holds do not expire.
1062+ // Report them alone, at the last Seen that was safe.
1063+ o.report(nil, o.seenSnapshot)
1064+ readWait = readWait || o.capped
10381065 }
10391066 }
10401067 o.flushReport(presented)
@@ -1103,6 +1130,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11031130 case b := <-pipeline.Completed():
11041131 pipeline.Recycle(b, r)
11051132 continue
1133+ case b := <-pipeline.HiddenCompleted():
1134+ pipeline.RecycleHidden(b)
1135+ pipeline.Retire(scene)
1136+ continue
11061137 case q := <-captures:
11071138 if !enabled || o.off || o.wantOff {
11081139 capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
@@ -1173,9 +1204,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11731204 reportDirty = true
11741205 }
11751206 // Windows on other outputs or workspaces do not need a frame.
1176- dirty = dirty || scene.Shows(c.ID)
1207+ dirty = dirty || capture.Shows(scene, c.ID)
11771208 case <-stats.C():
11781209 now := clk.Now()
1210+ pipeline.Retire(scene) // an idle child of an ended session goes
11791211 o.dropClientFBs(now, false)
11801212 // An idle output renders nothing: free what windows that left
11811213 // it held.
@@ -1240,6 +1272,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12401272 if errors.As(err, &fatal) {
12411273 // A failed render did not hand these requests to the worker.
12421274 for _, q := range requests {
1275+ if capture.Handed(q) {
1276+ continue // already owned by the worker, or answered
1277+ }
12431278 capture.Fail(ctx, q, err, captured)
12441279 }
12451280 }
@@ -1361,10 +1396,18 @@ const maxUnsent = 16
13611396 // buffers can be released. A flip-less report merges into the newest
13621397 // queued one; flips are never merged unless maxUnsent is reached.
13631398 func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
1399+ o.capped = false
1400+ var reads map[ports.WindowID]uint64
1401+ if o.capHidden != nil {
1402+ seen, reads, o.capped = o.capHidden(seen)
1403+ }
13641404 if !maps.Equal(o.seenSnapshot, seen) {
13651405 o.seenSnapshot = maps.Clone(seen)
13661406 }
1367- r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: o.seenSnapshot}
1407+ if !maps.Equal(o.childReadsSnapshot, reads) {
1408+ o.childReadsSnapshot = maps.Clone(reads)
1409+ }
1410+ r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: o.seenSnapshot, ChildReads: o.childReadsSnapshot}
13681411 if n := len(o.unsent); n > 0 && (flip == nil || o.unsent[n-1].Flip == nil) {
13691412 if flip == nil {
13701413 r.Flip = o.unsent[n-1].Flip
A internal/adapters/headless/capture_session_test.go +277 -0
@@ -0,0 +1,277 @@
1+package headless
2+
3+import (
4+ "context"
5+ "errors"
6+ "image"
7+ "os"
8+ "reflect"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/neferwl/internal/adapters/capture"
14+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
15+ "github.com/bnema/neferwl/internal/ports"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+type events struct {
21+ mu sync.Mutex
22+ log []string
23+}
24+
25+func (e *events) add(s string) {
26+ e.mu.Lock()
27+ defer e.mu.Unlock()
28+ e.log = append(e.log, s)
29+}
30+
31+func (e *events) get() []string {
32+ e.mu.Lock()
33+ defer e.mu.Unlock()
34+ return append([]string(nil), e.log...)
35+}
36+
37+func sessionCaptureFile(t *testing.T) ports.SHMBuffer {
38+ t.Helper()
39+ f, err := os.CreateTemp(t.TempDir(), "capture")
40+ require.NoError(t, err)
41+ t.Cleanup(func() { _ = f.Close() })
42+ require.NoError(t, f.Truncate(16))
43+ return ports.SHMBuffer{File: f}
44+}
45+
46+// runSession drives one frame with the given requests and returns the
47+// renderer event order and the replies.
48+func runSession(t *testing.T, scene ports.Scene, reqs []ports.CaptureRequest, wantReplies int) ([]string, []ports.CaptureDone) {
49+ t.Helper()
50+ ev := &events{}
51+ r := portsmocks.NewMockRenderer(t)
52+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
53+ // Clean scenes are renumbered 0; the tests' displayed scenes are not.
54+ if s.Seq == 0 {
55+ ev.add("render:clean")
56+ } else {
57+ ev.add("render:full")
58+ }
59+ return nil, nil
60+ }).Maybe()
61+ frame := portsmocks.NewMockCaptureFrame(t)
62+ frame.EXPECT().Done().Return(nil).Maybe()
63+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
64+ r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) {
65+ ev.add("begin")
66+ return frame, nil
67+ }).Maybe()
68+ r.EXPECT().EndCapture(frame).Return().Maybe()
69+ r.EXPECT().Close().Return().Once()
70+ ctx, cancel := context.WithCancel(context.Background())
71+ defer cancel()
72+ scenes := make(chan ports.Scene, 1)
73+ incoming := make(chan ports.CaptureRequest)
74+ replies := make(chan ports.CaptureDone, 8)
75+ done := make(chan error, 1)
76+ go func() {
77+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming)
78+ }()
79+ // Requests wait for a scene: all of them belong to its first frame.
80+ for _, q := range reqs {
81+ q.Region, q.Width, q.Height, q.Stride, q.Format = image.Rect(0, 0, 2, 2), 2, 2, 8, 1
82+ q.Dst = sessionCaptureFile(t)
83+ incoming <- q
84+ }
85+ scenes <- scene
86+ var got []ports.CaptureDone
87+ for range wantReplies {
88+ select {
89+ case d := <-replies:
90+ got = append(got, d)
91+ case <-time.After(3 * time.Second):
92+ t.Fatal("missing capture reply")
93+ }
94+ }
95+ cancel()
96+ require.NoError(t, <-done)
97+ return ev.get(), got
98+}
99+
100+func sessionScene() ports.Scene {
101+ return ports.Scene{Seq: 2, Windows: []ports.SceneWindow{{ID: 1}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}},
102+ Capture: &ports.SceneCapture{Session: 7, Excluded: []ports.WindowID{10}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}}
103+}
104+
105+func TestCleanCaptureOrderedBeforeDisplayedFrame(t *testing.T) {
106+ order, replies := runSession(t, sessionScene(), []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}}, 2)
107+ // Clean composition and its copy come first; the displayed frame and
108+ // the standard capture (HUD and border included) follow.
109+ require.Equal(t, []string{"render:clean", "begin", "render:full", "begin"}, order)
110+ for _, d := range replies {
111+ require.NoError(t, d.Err)
112+ }
113+}
114+
115+func TestCleanCaptureFailsClosedWithoutMatchingSession(t *testing.T) {
116+ scene := sessionScene()
117+ scene.Capture = nil
118+ order, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}}, 2)
119+ require.Equal(t, []string{"render:full", "begin"}, order) // one frame, the standard capture only
120+ byID := map[uint64]error{}
121+ for _, d := range replies {
122+ byID[d.ID] = d.Err
123+ }
124+ require.ErrorIs(t, byID[1], capture.ErrSessionInactive)
125+ require.NoError(t, byID[2])
126+}
127+
128+func TestStandardCaptureDuringSessionUsesOneComposition(t *testing.T) {
129+ order, replies := runSession(t, sessionScene(), []ports.CaptureRequest{{ID: 2}}, 1)
130+ require.Equal(t, []string{"render:full", "begin"}, order)
131+ require.NoError(t, replies[0].Err)
132+}
133+
134+// A hidden workspace is drawn by the child renderer (made by the factory),
135+// not by the display's; the display still renders the scene it shows.
136+func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) {
137+ read, write, err := os.Pipe()
138+ require.NoError(t, err)
139+ defer write.Close()
140+ display := portsmocks.NewMockRenderer(t)
141+ display.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
142+ display.EXPECT().Close().Return().Once()
143+ child := portsmocks.NewMockRenderer(t)
144+ frame := portsmocks.NewMockCaptureFrame(t)
145+ frame.EXPECT().Done().Return(nil).Maybe()
146+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
147+ child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
148+ require.Nil(t, s.Capture)
149+ require.Len(t, s.Layers, 0, "HUD left out of the child frame")
150+ return read, nil
151+ }).Once()
152+ child.EXPECT().BeginCapture().Return(frame, nil).Once()
153+ child.EXPECT().EndCapture(frame).Return().Maybe()
154+ child.EXPECT().Close().Return().Once()
155+ scene := sessionScene()
156+ scene.OutputWidth, scene.OutputHeight, scene.Scale = 2, 2, 1
157+ scene.Capture.TargetRect = ports.Rect{W: 2, H: 2}
158+ scene.CaptureScene = &ports.Scene{OutputWidth: 2, OutputHeight: 2, Scale: 1, Windows: []ports.SceneWindow{{ID: 5}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}}}
159+ ctx, cancel := context.WithCancel(context.Background())
160+ defer cancel()
161+ scenes := make(chan ports.Scene, 1)
162+ incoming := make(chan ports.CaptureRequest)
163+ replies := make(chan ports.CaptureDone, 2)
164+ done := make(chan error, 1)
165+ var made [2]int
166+ presented := make(chan ports.OutputPresented, 4)
167+ go func() {
168+ done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, Presented: presented,
169+ NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil },
170+ NewCaptureRenderer: func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil },
171+ }, scenes, nil, nil, incoming)
172+ }()
173+ q := ports.CaptureRequest{ID: 1, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
174+ incoming <- q
175+ scenes <- scene
176+ // The owner may report an idle frame before receiving the first scene.
177+ deadline := time.After(3 * time.Second)
178+waitHold:
179+ for {
180+ select {
181+ case report := <-presented:
182+ if len(report.ChildReads) > 0 {
183+ require.Equal(t, map[ports.WindowID]uint64{5: 0}, report.ChildReads, "hold published before waiting")
184+ break waitHold
185+ }
186+ case <-deadline:
187+ t.Fatal("no child hold report")
188+ }
189+ }
190+ select {
191+ case <-presented:
192+ t.Fatal("child hold lifted before its fence signalled")
193+ case <-time.After(120 * time.Millisecond): // beyond Wayland's 100ms timeout
194+ }
195+ _, err = write.Write([]byte{1})
196+ require.NoError(t, err)
197+ select {
198+ case report := <-presented:
199+ require.Empty(t, report.ChildReads, "completed render lifts child hold")
200+ case <-time.After(3 * time.Second):
201+ t.Fatal("no completed render report")
202+ }
203+ select {
204+ case d := <-replies:
205+ require.NoError(t, d.Err)
206+ case <-time.After(3 * time.Second):
207+ t.Fatal("no reply")
208+ }
209+ cancel()
210+ require.NoError(t, <-done)
211+ require.Equal(t, [2]int{2, 2}, made)
212+}
213+
214+// GO007: a render error while a clean request was already handed to a worker
215+// must not answer it a second time as request 0.
216+func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) {
217+ display := portsmocks.NewMockRenderer(t)
218+ boom := errors.New("boom")
219+ display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq == 0 }), mock.Anything).Return(nil, nil).Once() // clean frame
220+ display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq != 0 }), mock.Anything).Return(nil, boom).Once()
221+ frame := portsmocks.NewMockCaptureFrame(t)
222+ frame.EXPECT().Done().Return(nil).Maybe()
223+ frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
224+ display.EXPECT().BeginCapture().Return(frame, nil).Once()
225+ display.EXPECT().EndCapture(frame).Return().Maybe()
226+ display.EXPECT().Close().Return().Once()
227+ ctx, cancel := context.WithCancel(context.Background())
228+ defer cancel()
229+ scenes := make(chan ports.Scene, 1)
230+ incoming := make(chan ports.CaptureRequest)
231+ replies := make(chan ports.CaptureDone, 8)
232+ done := make(chan error, 1)
233+ go func() {
234+ done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies,
235+ NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }}, scenes, nil, nil, incoming)
236+ }()
237+ incoming <- ports.CaptureRequest{ID: 1, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
238+ scenes <- sessionScene()
239+ select {
240+ case err := <-done:
241+ require.ErrorIs(t, err, boom)
242+ case <-time.After(3 * time.Second):
243+ t.Fatal("output did not stop on the render error")
244+ }
245+ var ids []uint64
246+ for len(replies) > 0 {
247+ ids = append(ids, (<-replies).ID)
248+ }
249+ require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0")
250+}
251+
252+// GO-003: an unchanged child-hold publication reuses its immutable snapshots;
253+// a change clones, and earlier reports keep their maps.
254+func TestHoldSnapshotsCloneOnlyOnChange(t *testing.T) {
255+ var h holdSnapshots
256+ seen := map[ports.WindowID]uint64{1: 4, 5: 7}
257+ reads := map[ports.WindowID]uint64{5: 2}
258+ first := h.report("HEADLESS-1", seen, reads)
259+ require.Equal(t, seen, first.Seen)
260+ require.Equal(t, reads, first.ChildReads)
261+ seen[1] = 99 // the caller's scratch changes: the report does not
262+ require.Equal(t, uint64(4), first.Seen[1])
263+
264+ seen[1] = 4
265+ again := h.report("HEADLESS-1", seen, reads)
266+ require.Equal(t, reflect.ValueOf(first.Seen).Pointer(), reflect.ValueOf(again.Seen).Pointer(), "unchanged seen is shared")
267+ require.Equal(t, reflect.ValueOf(first.ChildReads).Pointer(), reflect.ValueOf(again.ChildReads).Pointer(), "unchanged reads are shared")
268+ allocs := testing.AllocsPerRun(100, func() { _ = h.report("HEADLESS-1", seen, reads) })
269+ require.Zero(t, allocs, "unchanged publication allocates nothing")
270+ t.Logf("unchanged hidden-capture publication: %.0f allocs", allocs)
271+
272+ reads[5] = 3
273+ changed := h.report("HEADLESS-1", seen, reads)
274+ require.Equal(t, map[ports.WindowID]uint64{5: 3}, changed.ChildReads)
275+ require.Equal(t, map[ports.WindowID]uint64{5: 2}, first.ChildReads, "sent snapshot stays immutable")
276+ require.Equal(t, reflect.ValueOf(first.Seen).Pointer(), reflect.ValueOf(changed.Seen).Pointer(), "seen still unchanged")
277+}
M internal/adapters/headless/output.go +78 -5
@@ -29,6 +29,9 @@ type Options struct {
2929 Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting
3030 Log zerowrap.Logger
3131 NewRenderer func(w, h int) (ports.Renderer, error)
32+ // NewCaptureRenderer makes the child renderer of a hidden workspace
33+ // capture session (Scene.CaptureScene). Nil: such captures fail closed.
34+ NewCaptureRenderer func(w, h int) (ports.Renderer, error)
3235 // Name and Presented report what the output has read after each
3336 // frame, so wayland can release client buffers (nil: no reports).
3437 Name string
@@ -80,10 +83,16 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
8083 requests := requestStorage[:0]
8184 ctx, cancelCaptures := context.WithCancel(ctx)
8285 pipeline := capture.NewPipeline(ctx, opts.Captured)
86+ if opts.NewCaptureRenderer != nil {
87+ pipeline.EnableOffscreen(opts.NewCaptureRenderer)
88+ }
8389 defer func() {
8490 cancelCaptures()
8591 pipeline.Close(r)
8692 for _, q := range requests {
93+ if capture.Handed(q) {
94+ continue // already answered, or owned by a worker
95+ }
8796 capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
8897 }
8998 for {
@@ -98,11 +107,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
98107 var want ports.CursorChange
99108 cursorScale := -1.0 // not loaded yet
100109 seen := map[ports.WindowID]uint64{}
110+ var holds holdSnapshots
101111 // pending is a report the channel could not take, retried soon.
102112 var pending *ports.OutputPresented
103113 update := func(c ports.SurfaceContent) {
104114 seen[c.ID] = max(seen[c.ID], c.Seq)
105- dirty = dirty || scene.Shows(c.ID)
115+ dirty = dirty || capture.Shows(scene, c.ID)
106116 if c.Empty() {
107117 delete(surfaces, c.ID)
108118 } else {
@@ -129,6 +139,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
129139 case b := <-pipeline.Completed():
130140 pipeline.Recycle(b, r)
131141 continue
142+ case b := <-pipeline.HiddenCompleted():
143+ pipeline.RecycleHidden(b)
144+ pipeline.Retire(scene)
145+ continue
132146 case q := <-incoming:
133147 if scene.Off {
134148 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
@@ -143,7 +157,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
143157 continue
144158 case <-trim.C():
145159 // An idle output renders nothing: free what windows that left
146- // it held.
160+ // it held, and the child of an ended session.
161+ pipeline.Retire(scene)
147162 if err := r.Trim(clk.Now()); err != nil {
148163 return fmt.Errorf("trim renderer: %w", err)
149164 }
@@ -207,6 +222,46 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
207222 }
208223 }
209224 start := time.Now()
225+ // Clean requests of a session the scene does not carry are failed
226+ // here. The others draw the clean frame first (same renderer, queue
227+ // order), then the displayed one; see capture.Pipeline.Split.
228+ normal, clean := pipeline.Split(scene, requests)
229+ pipeline.Retire(scene)
230+ if len(clean) > 0 && scene.CaptureScene != nil {
231+ // A hidden workspace: a child renderer draws it. The display's
232+ // fences do not cover the child, so wait for it before reporting.
233+ pipeline.SubmitHidden(scene, surfaces, clean)
234+ clear(clean)
235+ // A slow child may outlive Wayland's stale-report timeout even
236+ // though this owner waits. Publish its non-expiring holds first.
237+ if opts.Presented != nil {
238+ _, reads, _ := pipeline.CapHiddenSeen(seen)
239+ if len(reads) > 0 {
240+ r := holds.report(opts.Name, seen, reads)
241+ select {
242+ case opts.Presented <- r:
243+ case <-ctx.Done():
244+ return nil
245+ }
246+ }
247+ }
248+ if err := pipeline.WaitHidden(ctx); err != nil {
249+ if ctx.Err() != nil {
250+ return nil
251+ }
252+ return fmt.Errorf("workspace frame fence: %w", err)
253+ }
254+ } else if len(clean) > 0 {
255+ cdone, err := r.Render(pipeline.CleanScene(scene), surfaces)
256+ if err != nil {
257+ return fmt.Errorf("render clean frame: %w", err)
258+ }
259+ if cdone != nil {
260+ cdone.Close()
261+ }
262+ pipeline.Submit(r, clean)
263+ clear(clean)
264+ }
210265 done, err := r.Render(scene, surfaces)
211266 if err != nil {
212267 return fmt.Errorf("render frame: %w", err)
@@ -223,10 +278,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
223278 return fmt.Errorf("frame fence: %w", err)
224279 }
225280 }
226- if len(requests) > 0 {
227- opts.Log.Debug().Str("output", opts.Name).Int("captures", len(requests)).Msg("capture frame composed")
281+ if len(normal) > 0 {
282+ opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed")
228283 }
229- pipeline.Submit(r, requests)
284+ pipeline.Submit(r, normal)
230285 clear(requests)
231286 requests = requestStorage[:0]
232287 frame++
@@ -251,6 +306,24 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
251306 }
252307 }
253308
309+// holdSnapshots caches the immutable maps of the child-hold reports. The
310+// receiver may still read an earlier report on another goroutine, so a cached
311+// map is never mutated: it is replaced by a clone when the content changes,
312+// and an unchanged publication allocates nothing.
313+type holdSnapshots struct {
314+ seen, reads map[ports.WindowID]uint64
315+}
316+
317+func (h *holdSnapshots) report(output string, seen, reads map[ports.WindowID]uint64) ports.OutputPresented {
318+ if !maps.Equal(h.seen, seen) {
319+ h.seen = maps.Clone(seen)
320+ }
321+ if !maps.Equal(h.reads, reads) {
322+ h.reads = maps.Clone(reads)
323+ }
324+ return ports.OutputPresented{Output: output, Seen: h.seen, ChildReads: h.reads}
325+}
326+
254327 func writePNG(path string, img *image.RGBA) error {
255328 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
256329 if err != nil {
A internal/adapters/vulkan/capture_session_bench_test.go +88 -0
@@ -0,0 +1,88 @@
1+package vulkan
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// BenchmarkCaptureSessionFrame compares what a frame costs (CPU recording plus
12+// GPU completion, waited on the CPU) with no capture, one standard capture, and
13+// a clean capture plus the displayed frame (the second composition of the
14+// exclusion path). Scenes have Seq 0: every frame is a full redraw, as when
15+// the scene changes each frame.
16+//
17+// go test ./internal/adapters/vulkan -run '^$' -bench CaptureSessionFrame -benchmem
18+func BenchmarkCaptureSessionFrame(b *testing.B) {
19+ for _, size := range [][2]int{{320, 240}, {1920, 1080}} {
20+ w, h := size[0], size[1]
21+ r, err := New(w, h)
22+ if err != nil {
23+ b.Skipf("Vulkan unavailable: %v", err)
24+ }
25+ if !r.syncFD || !r.dd.HasGetSemaphoreFdKHR() {
26+ r.Close()
27+ b.Skip("Vulkan sync-file export unavailable")
28+ }
29+ win := solidContent(&testing.T{}, 64, 64, color.RGBA{G: 200, A: 255})
30+ hud := solidContent(&testing.T{}, 64, 16, color.RGBA{B: 200, A: 255})
31+ win.ID, win.Surface, win.Seq, win.Version = 1, 1, 1, 1
32+ hud.ID, hud.Surface, hud.Seq, hud.Version = 10, 2, 1, 1
33+ contents := map[ports.WindowID]ports.SurfaceContent{1: win, 10: hud}
34+ shown := ports.Scene{
35+ Scale: 1, Background: "#101010",
36+ Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: w, H: h}}},
37+ Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop, Rect: ports.Rect{X: 8, Y: 8, W: 64, H: 16}}},
38+ Capture: &ports.SceneCapture{Session: 1, TargetRect: ports.Rect{W: w, H: h}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor, Excluded: []ports.WindowID{10}},
39+ }
40+ clean := shown
41+ clean.Capture, clean.Layers = nil, nil
42+ frame := func(b *testing.B, s ports.Scene, capture bool) {
43+ if err := render(r, s, contents); err != nil {
44+ b.Fatal(err)
45+ }
46+ if capture {
47+ cf, err := r.BeginCapture()
48+ if err != nil {
49+ b.Fatal(err)
50+ }
51+ defer r.EndCapture(cf)
52+ }
53+ }
54+ wait := func(b *testing.B) {
55+ if err := r.waitFrame(r.submitted); err != nil {
56+ b.Fatal(err)
57+ }
58+ for _, s := range r.captures {
59+ if s.pending {
60+ if err := checked("wait", r.dd.WaitForFences(r.device, 1, &s.fence, 1, 1<<62)); err != nil {
61+ b.Fatal(err)
62+ }
63+ }
64+ }
65+ }
66+ for _, tc := range []struct {
67+ name string
68+ run func(b *testing.B)
69+ }{
70+ {"render", func(b *testing.B) { frame(b, shown, false) }},
71+ {"render+capture", func(b *testing.B) { frame(b, shown, true) }},
72+ {"clean+capture+render", func(b *testing.B) { frame(b, clean, true); frame(b, shown, false) }},
73+ {"clean+capture+render+capture", func(b *testing.B) { frame(b, clean, true); frame(b, shown, true) }},
74+ } {
75+ b.Run(fmt.Sprintf("%dx%d/%s", w, h, tc.name), func(b *testing.B) {
76+ tc.run(b)
77+ wait(b)
78+ b.ReportAllocs()
79+ b.ResetTimer()
80+ for range b.N {
81+ tc.run(b)
82+ wait(b)
83+ }
84+ })
85+ }
86+ r.Close()
87+ }
88+}
A internal/adapters/vulkan/capture_session_test.go +127 -0
@@ -0,0 +1,127 @@
1+package vulkan
2+
3+import (
4+ "context"
5+ "image"
6+ "image/color"
7+ "math"
8+ "testing"
9+
10+ "github.com/bnema/neferwl/internal/adapters/capture"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+var sessionRed = color.RGBA{R: 0xff, G: 0x3b, B: 0x30, A: 255}
16+
17+func sessionScene() ports.Scene {
18+ return ports.Scene{
19+ Seq: 1, Scale: 1, Background: "#101010",
20+ Capture: &ports.SceneCapture{
21+ Session: 7, TargetRect: ports.Rect{X: 8, Y: 4, W: 16, H: 8},
22+ BorderWidth: 2, BorderColor: ports.CaptureBorderColor, Excluded: []ports.WindowID{9},
23+ },
24+ }
25+}
26+
27+func TestCaptureBorderGeometryLogicalScaled(t *testing.T) {
28+ for _, tc := range []struct {
29+ scale float64
30+ want []image.Rectangle
31+ }{
32+ {1, []image.Rectangle{image.Rect(8, 4, 24, 6), image.Rect(8, 10, 24, 12), image.Rect(8, 6, 10, 10), image.Rect(22, 6, 24, 10)}},
33+ {2, []image.Rectangle{image.Rect(16, 8, 48, 12), image.Rect(16, 20, 48, 24), image.Rect(16, 12, 20, 20), image.Rect(44, 12, 48, 20)}},
34+ } {
35+ r := &Renderer{width: 64, height: 64}
36+ s := sessionScene()
37+ s.Scale = tc.scale
38+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))
39+ require.Len(t, ds, 4)
40+ for i, d := range ds {
41+ got := image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))
42+ require.Equal(t, tc.want[i], got, "scale %v side %d", tc.scale, i)
43+ require.Equal(t, [4]float32{0xff / 255.0, 0x3b / 255.0, 0x30 / 255.0, 1}, d.pc.color)
44+ }
45+ }
46+ // On the output edge the border is still inside the output.
47+ r := &Renderer{width: 64, height: 64}
48+ s := sessionScene()
49+ s.Scale = 1
50+ s.Capture.TargetRect = ports.Rect{W: 64, H: 64}
51+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))
52+ require.Len(t, ds, 4)
53+ require.Equal(t, [4]int32{0, 0, 64, 2}, ds[0].pc.rect)
54+ require.Equal(t, [4]int32{62, 2, 64, 62}, ds[3].pc.rect)
55+ // A width larger than half the smaller side is clamped: no overdraw.
56+ s.Capture.TargetRect, s.Capture.BorderWidth = ports.Rect{X: 4, Y: 4, W: 10, H: 6}, 9
57+ ds = r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))
58+ require.Equal(t, [4]int32{4, 4, 14, 7}, ds[0].pc.rect)
59+ require.Equal(t, [4]int32{4, 7, 14, 10}, ds[1].pc.rect)
60+ require.Len(t, ds, 2) // the side fills would be empty
61+ // No session, or no border: nothing extra is drawn.
62+ s = sessionScene()
63+ s.Capture.BorderWidth = 0
64+ require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
65+ s.Capture = nil
66+ require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))))
67+}
68+
69+func pixelAt(t *testing.T, r *Renderer, cf ports.CaptureFrame, x, y int) color.RGBA {
70+ t.Helper()
71+ dst := make([]byte, 4)
72+ require.NoError(t, cf.Read(image.Rect(x, y, x+1, y+1), dst, 4))
73+ return color.RGBA{dst[2], dst[1], dst[0], 255}
74+}
75+
76+// The clean frame is rendered and copied first, then the displayed frame:
77+// the first copy has no border, the second one does, and the displayed frame
78+// (Pixels) matches the second.
79+func testCleanThenDisplayed(t *testing.T, r *Renderer, tol int, red color.RGBA) {
80+ s := sessionScene()
81+ p := capture.NewPipeline(context.Background(), nil)
82+ defer p.Close(nil)
83+ clean := p.CleanScene(s)
84+ require.Nil(t, clean.Capture)
85+ require.Zero(t, clean.Seq)
86+ require.NoError(t, render(r, clean, nil))
87+ cleanCopy := captureWait(t, r)
88+ defer r.EndCapture(cleanCopy)
89+ require.NoError(t, render(r, s, nil))
90+ shownCopy := captureWait(t, r)
91+ defer r.EndCapture(shownCopy)
92+ near := func(a, b color.RGBA) bool {
93+ d := func(x, y uint8) int { return max(int(x)-int(y), int(y)-int(x)) }
94+ return max(d(a.R, b.R), d(a.G, b.G), d(a.B, b.B)) <= tol
95+ }
96+ bg := pixelAt(t, r, shownCopy, 1, 1)
97+ // (8, 8) and (23, 8) lie in the 2 px border on the target's edge, (12, 8) inside it.
98+ require.True(t, near(pixelAt(t, r, shownCopy, 8, 8), red), "displayed border")
99+ require.True(t, near(pixelAt(t, r, shownCopy, 23, 8), red), "displayed border")
100+ require.True(t, near(pixelAt(t, r, cleanCopy, 8, 8), bg), "clean copy has no border")
101+ require.True(t, near(pixelAt(t, r, cleanCopy, 23, 8), bg), "clean copy has no border")
102+ require.True(t, near(pixelAt(t, r, shownCopy, 7, 8), bg), "nothing outside the target")
103+ require.True(t, near(pixelAt(t, r, shownCopy, 12, 8), bg), "target interior untouched")
104+}
105+
106+func TestCaptureCleanBeforeDisplayedSDR(t *testing.T) {
107+ r, err := New(64, 16)
108+ if err != nil {
109+ t.Skipf("Vulkan unavailable: %v", err)
110+ }
111+ defer r.Close()
112+ testCleanThenDisplayed(t, r, 0, sessionRed)
113+}
114+
115+func TestCaptureCleanBeforeDisplayedHDR(t *testing.T) {
116+ r := hdrTestRenderer(t)
117+ // The HDR capture pass tone-maps: the expectation is the same curve.
118+ lin := func(v uint8) float64 {
119+ f := float64(v) / 255
120+ if f <= .04045 {
121+ return f / 12.92
122+ }
123+ return math.Pow((f+.055)/1.055, 2.4)
124+ }
125+ want := captureSDR([3]float64{lin(sessionRed.R), lin(sessionRed.G), lin(sessionRed.B)})
126+ testCleanThenDisplayed(t, r, 3, color.RGBA{want[0], want[1], want[2], 255})
127+}
M internal/adapters/vulkan/scene.go +23 -0
@@ -43,10 +43,33 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
4343 w.bounds = outputBounds
4444 w.layers(true)
4545 w.popups(true)
46+ w.captureBorder()
4647 r.scratchDraws = w.draws
4748 return w.draws
4849 }
4950
51+// captureBorder draws the native border of a recording session over
52+// everything: four fills on the inside edge of Capture.TargetRect, so it stays
53+// visible on a monitor edge. Sizes are logical like the rest of the scene and
54+// scale with it; the width is at most half the smaller side. A scene without
55+// Capture, or without border, draws nothing (clean captures never carry it).
56+func (w *sceneWalk) captureBorder() {
57+ c := w.s.Capture
58+ if c == nil || c.BorderColor == "" {
59+ return
60+ }
61+ t := c.TargetRect
62+ b := min(c.BorderWidth, t.W/2, t.H/2)
63+ if b <= 0 {
64+ return
65+ }
66+ col := parseColor(c.BorderColor)
67+ w.fill(w.physRect(t.X, t.Y, t.W, b), col)
68+ w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col)
69+ w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col)
70+ w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col)
71+}
72+
5073 func (w *sceneWalk) phys(v int) int { return int(math.Round(float64(v) * w.scale)) }
5174
5275 func (w *sceneWalk) physRect(x, y, width, height int) image.Rectangle {
A internal/adapters/wayland/capture_session.go +423 -0
@@ -0,0 +1,423 @@
1+package wayland
2+
3+import (
4+ "crypto/rand"
5+ "crypto/subtle"
6+ "encoding/hex"
7+ "os"
8+ "slices"
9+ "time"
10+
11+ "github.com/bnema/neferwl/internal/adapters/wayland/capturesession"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/purego-libwayland/protocol/wayland"
14+ "github.com/bnema/purego-libwayland/server"
15+)
16+
17+// Private capture sessions (neferwl_capture_manager_v1, capturesession/neferwl-capture-v1.xml).
18+// Core owns the session state; this file relays requests, tells clients what
19+// core decided and tags the capture requests of the session's own connection.
20+//
21+// Privacy: the session token never reaches a log, and neither does any peer
22+// identity; only session lifecycle states are logged.
23+
24+// privateCapture is the server's private capture state; display goroutine only.
25+type privateCapture struct {
26+ ownUID uint32
27+ managers []*captureManager
28+ next uint64
29+ session *privateSession // the one live session
30+ // dead are ended sessions whose owner has not destroyed the object yet.
31+ dead []*privateSession
32+}
33+
34+// minPingGap drops pings that follow the previous one too closely: the
35+// timeout is seconds, so an owner cannot flood core with them.
36+const minPingGap = ports.CaptureSessionTimeout / 50
37+
38+type privateSession struct {
39+ s *Server
40+ id uint64
41+ token string
42+ res *capturesession.NeferwlCaptureSessionV1
43+ client server.Client // the owner's connection: its captures are clean
44+ output string
45+ // layers are the attached layer surfaces; attached their attachment
46+ // objects, pending until a core state lists the layer.
47+ layers []*layerSurface
48+ attached []*layerAttachment
49+ lastPing time.Time
50+ // confirmed is set by the first core state; active and revision are the
51+ // last state's: clean captures are tagged with them.
52+ confirmed, active bool
53+ revision uint64
54+}
55+
56+// layerAttachment is one neferwl_capture_layer_v1: it reports the result of
57+// an attach_surface and the layer's end.
58+type layerAttachment struct {
59+ res *capturesession.NeferwlCaptureLayerV1
60+ layer *layerSurface
61+ confirmed bool // attached sent
62+}
63+
64+func (a *layerAttachment) fail(reason capturesession.NeferwlCaptureLayerV1Failure) {
65+ if a.res.Alive() {
66+ a.res.SendFailed(uint32(reason))
67+ }
68+}
69+
70+// finish ends an attachment: detached after attached, failed before it.
71+func (a *layerAttachment) finish(failure capturesession.NeferwlCaptureLayerV1Failure, detach capturesession.NeferwlCaptureLayerV1DetachReason) {
72+ if !a.res.Alive() {
73+ return
74+ }
75+ if a.confirmed {
76+ a.res.SendDetached(uint32(detach))
77+ } else {
78+ a.res.SendFailed(uint32(failure))
79+ }
80+}
81+
82+// trustedPeer reports whether a peer with these credentials may use the
83+// private protocol: its credentials are readable and it runs as the
84+// compositor's own user.
85+func trustedPeer(own uint32, peer server.Credentials, err error) bool {
86+ return err == nil && peer.UID == own
87+}
88+
89+func (s *Server) trusted(c server.Client) bool {
90+ cr, err := c.Credentials()
91+ return trustedPeer(s.private.ownUID, cr, err)
92+}
93+
94+func registerCaptureSession(d *server.Display, s *Server) error {
95+ s.private.ownUID = uint32(os.Geteuid())
96+ return capturesession.NewNeferwlCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
97+ m := &captureManager{s: s, trusted: s.trusted(c), known: map[uint64]privateWorkspace{}}
98+ r, err := capturesession.NewNeferwlCaptureManagerV1(c, int32(v), id, m)
99+ if err != nil {
100+ return
101+ }
102+ m.res = r
103+ s.private.managers = append(s.private.managers, m)
104+ r.OnDestroy = func() { s.private.managers = removeItem(s.private.managers, m) }
105+ m.update(s.workspaceSnapshot)
106+ })
107+}
108+
109+type privateWorkspace struct {
110+ output string
111+ info ports.WorkspaceInfo
112+}
113+
114+type captureManager struct {
115+ s *Server
116+ res *capturesession.NeferwlCaptureManagerV1
117+ trusted bool
118+ known map[uint64]privateWorkspace
119+}
120+
121+// update sends the workspaces that are new or changed, and the ones gone.
122+// Untrusted peers get no inventory.
123+func (m *captureManager) update(snapshot ports.Workspaces) {
124+ if !m.trusted || !m.res.Alive() {
125+ return
126+ }
127+ now := map[uint64]privateWorkspace{}
128+ for _, out := range snapshot.Outputs {
129+ for _, w := range out.Workspaces {
130+ now[w.ID] = privateWorkspace{out.Name, w}
131+ }
132+ }
133+ for id := range m.known {
134+ if _, ok := now[id]; !ok {
135+ m.res.SendWorkspaceRemoved(uint32(id>>32), uint32(id))
136+ }
137+ }
138+ for id, w := range now {
139+ if m.known[id] == w {
140+ continue
141+ }
142+ f, active := w.info.Frame, uint32(0)
143+ if w.info.Active {
144+ active = 1
145+ }
146+ m.res.SendWorkspace(uint32(id>>32), uint32(id), w.output, w.info.Name, int32(f.X), int32(f.Y), int32(f.W), int32(f.H), active)
147+ }
148+ m.known = now
149+}
150+
151+// updateCaptureWorkspaces relays a core workspace snapshot to the private managers.
152+func (s *Server) updateCaptureWorkspaces(snapshot ports.Workspaces) {
153+ for _, m := range s.private.managers {
154+ m.update(snapshot)
155+ }
156+}
157+
158+func (*captureManager) Destroy(*capturesession.NeferwlCaptureManagerV1) {}
159+
160+func (m *captureManager) BeginSession(r *capturesession.NeferwlCaptureManagerV1, id uint32, out *wayland.Output, x, y, w, h int32, hi, lo, record uint32) {
161+ s := m.s
162+ if out == nil {
163+ r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidOutput), "no output")
164+ return
165+ }
166+ if w < 0 || h < 0 {
167+ r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidRegion), "negative region size")
168+ return
169+ }
170+ ps := &privateSession{s: s, client: r.Client()}
171+ res, err := capturesession.NewNeferwlCaptureSessionV1(r.Client(), r.Version(), id, ps)
172+ if err != nil {
173+ return
174+ }
175+ ps.res = res
176+ stop := func(reason capturesession.NeferwlCaptureSessionV1StopReason) { res.SendStopped(uint32(reason)) }
177+ o := s.outputOf(out)
178+ switch {
179+ case !m.trusted:
180+ s.log.Info().Str("reason", string(ports.CaptureReasonUnauthorized)).Msg("capture session refused")
181+ stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized)
182+ return
183+ case s.private.session != nil:
184+ s.log.Info().Str("reason", string(ports.CaptureReasonBusy)).Msg("capture session refused")
185+ stop(capturesession.NeferwlCaptureSessionV1StopReasonBusy)
186+ return
187+ case o == nil:
188+ stop(capturesession.NeferwlCaptureSessionV1StopReasonOutputGone)
189+ return
190+ }
191+ var raw [32]byte
192+ if _, err := rand.Read(raw[:]); err != nil {
193+ s.log.Warn().Msg("capture session refused: no randomness")
194+ stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized)
195+ return
196+ }
197+ s.private.next++
198+ ps.id, ps.token, ps.output = s.private.next, hex.EncodeToString(raw[:]), o.name()
199+ s.private.session = ps
200+ res.OnDestroy = ps.release
201+ workspace := uint64(hi)<<32 | uint64(lo)
202+ s.log.Info().Uint64("session", ps.id).Str("output", ps.output).Uint64("workspace", workspace).Bool("record", record != 0).Msg("capture session begin")
203+ s.emit(ports.CaptureSessionBegin{ID: ps.id, Output: ps.output, Workspace: workspace, Region: ports.Rect{X: int(x), Y: int(y), W: int(w), H: int(h)}, Record: record != 0})
204+}
205+
206+func (m *captureManager) AttachSurface(r *capturesession.NeferwlCaptureManagerV1, id uint32, token string, surface *wayland.Surface) {
207+ s := m.s
208+ if surface == nil || s.surfaces[surface.Resource] == nil || surface.Resource.Client() != r.Client() {
209+ r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidSurface), "surface is not yours")
210+ return
211+ }
212+ state := s.surfaces[surface.Resource]
213+ l := state.layer
214+ att := &layerAttachment{layer: l}
215+ res, err := capturesession.NewNeferwlCaptureLayerV1(r.Client(), r.Version(), id, att)
216+ if err != nil {
217+ return
218+ }
219+ att.res = res
220+ // Same user as the compositor, and as the session's creator (who passed
221+ // the same check): nothing else can attach.
222+ ps := s.private.session
223+ switch {
224+ case !m.trusted: // the UID of this connection, checked once at bind
225+ att.fail(capturesession.NeferwlCaptureLayerV1FailureUnauthorized)
226+ return
227+ case ps == nil || subtle.ConstantTimeCompare([]byte(token), []byte(ps.token)) != 1:
228+ att.fail(capturesession.NeferwlCaptureLayerV1FailureUnknownToken)
229+ return
230+ case state.kind != roleLayer || l == nil:
231+ r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorSurfaceRole), "not a layer surface")
232+ return
233+ case l.pending.layer != ports.LayerTop && l.pending.layer != ports.LayerOverlay:
234+ r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidLayer), "layer must be top or overlay")
235+ return
236+ case l.pending.keyboard == 1:
237+ r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorExclusiveKeyboard), "exclusive keyboard")
238+ return
239+ case l.mapped || l.closed:
240+ r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidSurface), "already mapped")
241+ return
242+ case l.capture != nil:
243+ att.fail(capturesession.NeferwlCaptureLayerV1FailureAlreadyAttached)
244+ return
245+ case len(ps.layers) >= ports.MaxCaptureSessionLayers:
246+ att.fail(capturesession.NeferwlCaptureLayerV1FailureTooManyLayers)
247+ return
248+ }
249+ l.capture, l.attachment = ps, att
250+ l.pending.keyboard, l.current.keyboard = 0, 0
251+ ps.layers = append(ps.layers, l)
252+ ps.attached = append(ps.attached, att)
253+ // Queued before any LayerChanged of this layer: core excludes it from
254+ // the first scene that shows it.
255+ s.emit(ports.CaptureSessionLayer{ID: ps.id, Layer: l.id, Attached: true})
256+}
257+
258+func (*layerAttachment) Destroy(*capturesession.NeferwlCaptureLayerV1) {}
259+
260+func (ps *privateSession) Destroy(*capturesession.NeferwlCaptureSessionV1) {}
261+
262+func (ps *privateSession) Ping(*capturesession.NeferwlCaptureSessionV1) {
263+ if ps.s.private.session != ps {
264+ return
265+ }
266+ if now := time.Now(); now.Sub(ps.lastPing) >= minPingGap {
267+ ps.lastPing = now
268+ ps.s.emit(ports.CaptureSessionPing{ID: ps.id})
269+ }
270+}
271+
272+// release runs when the session object goes: destroy request or client
273+// disconnect. A live session ends and core is told to forget it.
274+func (ps *privateSession) release() {
275+ s := ps.s
276+ if s.private.session == ps {
277+ s.log.Info().Uint64("session", ps.id).Str("reason", "owner-gone").Msg("capture session end")
278+ ps.end(true)
279+ }
280+ s.private.dead = removeItem(s.private.dead, ps)
281+}
282+
283+// end ends the session: every attachment is told (detached, or failed before
284+// it was confirmed), its layer surfaces are closed so none lingers into the
285+// next session, and the owner's captures of the output stay refused until it
286+// destroys the session object. notify tells core.
287+func (ps *privateSession) end(notify bool) {
288+ s := ps.s
289+ if s.private.session != ps {
290+ return
291+ }
292+ s.private.session = nil
293+ if ps.res.Alive() {
294+ s.private.dead = append(s.private.dead, ps)
295+ }
296+ layers, attached := ps.layers, ps.attached
297+ ps.layers, ps.attached = nil, nil
298+ for _, a := range attached {
299+ a.finish(capturesession.NeferwlCaptureLayerV1FailureSessionEnded, capturesession.NeferwlCaptureLayerV1DetachReasonSessionEnded)
300+ }
301+ for _, l := range layers {
302+ l.capture, l.attachment = nil, nil
303+ // Closing unmaps the surface: the LayerChanged that removes it is
304+ // queued before the CaptureSessionEnd below.
305+ l.close()
306+ }
307+ if notify {
308+ s.emit(ports.CaptureSessionEnd{ID: ps.id})
309+ }
310+}
311+
312+// captureDetach drops a layer surface whose role is gone from its session.
313+// The caller unmapped it first, so the LayerChanged that removes the layer is
314+// already queued: core keeps excluding the layer until it reads that.
315+func (l *layerSurface) captureDetach() {
316+ ps, att := l.capture, l.attachment
317+ if ps == nil {
318+ return
319+ }
320+ l.capture, l.attachment = nil, nil
321+ ps.layers = removeItem(ps.layers, l)
322+ ps.attached = removeItem(ps.attached, att)
323+ att.finish(capturesession.NeferwlCaptureLayerV1FailureLayerDestroyed, capturesession.NeferwlCaptureLayerV1DetachReasonLayerDestroyed)
324+ if ps.s.private.session == ps {
325+ ps.s.emit(ports.CaptureSessionLayer{ID: ps.id, Layer: l.id})
326+ }
327+}
328+
329+// stopReason maps a terminal core reason to the wire.
330+func stopReason(r ports.CaptureReason) capturesession.NeferwlCaptureSessionV1StopReason {
331+ switch r {
332+ case ports.CaptureReasonOutputGone:
333+ return capturesession.NeferwlCaptureSessionV1StopReasonOutputGone
334+ case ports.CaptureReasonOutputOff:
335+ return capturesession.NeferwlCaptureSessionV1StopReasonOutputOff
336+ case ports.CaptureReasonPingTimeout:
337+ return capturesession.NeferwlCaptureSessionV1StopReasonPingTimeout
338+ case ports.CaptureReasonWorkspaceGone:
339+ return capturesession.NeferwlCaptureSessionV1StopReasonWorkspaceGone
340+ case ports.CaptureReasonBusy:
341+ return capturesession.NeferwlCaptureSessionV1StopReasonBusy
342+ case ports.CaptureReasonInvalidRegion:
343+ return capturesession.NeferwlCaptureSessionV1StopReasonInvalidRegion
344+ case ports.CaptureReasonUnauthorized:
345+ return capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized
346+ case ports.CaptureReasonTooManyExcluded:
347+ return capturesession.NeferwlCaptureSessionV1StopReasonTooManyExcluded
348+ }
349+ return capturesession.NeferwlCaptureSessionV1StopReasonRequested
350+}
351+
352+// captureState applies what core decided about the session.
353+func (s *Server) captureState(c ports.CaptureSessionState) {
354+ ps := s.private.session
355+ if ps == nil || ps.id != c.ID {
356+ return
357+ }
358+ if c.Reason.Terminal() {
359+ s.log.Info().Uint64("session", ps.id).Str("reason", string(c.Reason)).Msg("capture session end")
360+ if ps.res.Alive() {
361+ ps.res.SendStopped(uint32(stopReason(c.Reason)))
362+ }
363+ ps.end(false)
364+ return
365+ }
366+ ps.confirmed, ps.active, ps.revision = true, c.Active, c.Revision
367+ s.log.Debug().Uint64("session", ps.id).Bool("active", c.Active).Uint64("revision", c.Revision).Str("reason", string(c.Reason)).Msg("capture session state")
368+ if ps.res.Alive() {
369+ active := uint32(0)
370+ if c.Active {
371+ active = 1
372+ }
373+ ps.res.SendState(ps.token, active, int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H), uint32(c.Workspace>>32), uint32(c.Workspace), uint32(c.Revision>>32), uint32(c.Revision))
374+ }
375+ // A layer is confirmed once core lists it: only then is it safe for the
376+ // client to show it.
377+ for _, a := range ps.attached {
378+ if !a.confirmed && slices.Contains(c.Layers, a.layer.id) {
379+ a.confirmed = true
380+ if a.res.Alive() {
381+ a.res.SendAttached()
382+ }
383+ }
384+ }
385+}
386+
387+// stopCaptureOnOutput ends the session of an unplugged output without waiting for core.
388+func (s *Server) stopCaptureOnOutput(name string) {
389+ ps := s.private.session
390+ if ps == nil || ps.output != name {
391+ return
392+ }
393+ s.log.Info().Uint64("session", ps.id).Str("reason", string(ports.CaptureReasonOutputGone)).Msg("capture session end")
394+ if ps.res.Alive() {
395+ ps.res.SendStopped(uint32(capturesession.NeferwlCaptureSessionV1StopReasonOutputGone))
396+ }
397+ ps.end(true)
398+}
399+
400+// captureTag says how a capture of output o requested through life is
401+// served. The owner's connection gets clean captures of the session's output,
402+// fenced by the revision core last confirmed. While its session is unconfirmed,
403+// or after it ended and until the owner destroys the object, those captures
404+// are refused rather than served with the HUD in them. Every other connection,
405+// and the owner's captures of another output, are untouched.
406+func (s *Server) captureTag(life *server.Resource, o *output) (clean bool, session, revision uint64, ok bool) {
407+ if life == nil {
408+ return false, 0, 0, true
409+ }
410+ client := life.Client()
411+ if ps := s.private.session; ps != nil && client == ps.client && o.name() == ps.output {
412+ if !ps.confirmed || !ps.active {
413+ return false, 0, 0, false
414+ }
415+ return true, ps.id, ps.revision, true
416+ }
417+ for _, ps := range s.private.dead {
418+ if client == ps.client && o.name() == ps.output {
419+ return false, 0, 0, false
420+ }
421+ }
422+ return false, 0, 0, true
423+}
A internal/adapters/wayland/capture_session_test.go +813 -0
@@ -0,0 +1,813 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/wayland/capturesession"
10+ "github.com/bnema/neferwl/internal/logging"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "github.com/bnema/purego-libwayland/protocol/wayland"
13+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
14+ wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
15+ "github.com/bnema/purego-libwayland/server"
16+ "github.com/bnema/wlturbo"
17+ "golang.org/x/sys/unix"
18+)
19+
20+// sessionMsg is one decoded event of the private protocol.
21+type sessionMsg struct {
22+ op uint16
23+ token string
24+ name string
25+ numbers []int64
26+}
27+
28+// managerEvents and sessionEvents decode the events of the two interfaces.
29+type managerEvents struct {
30+ wlturbo.BaseProxy
31+ msgs chan sessionMsg
32+}
33+
34+func (p *managerEvents) Dispatch(e *wlturbo.Event) {
35+ m := sessionMsg{op: e.Opcode}
36+ switch uint32(e.Opcode) {
37+ case capturesession.NeferwlCaptureManagerV1EventWorkspace:
38+ m.numbers = append(m.numbers, int64(e.Uint32()), int64(e.Uint32()))
39+ _ = e.String()
40+ m.name = e.String()
41+ m.numbers = append(m.numbers, int64(e.Int32()), int64(e.Int32()), int64(e.Int32()), int64(e.Int32()), int64(e.Uint32()))
42+ case capturesession.NeferwlCaptureManagerV1EventWorkspaceRemoved:
43+ m.numbers = append(m.numbers, int64(e.Uint32()), int64(e.Uint32()))
44+ }
45+ p.msgs <- m
46+}
47+
48+// attachEvents decodes the events of a neferwl_capture_layer_v1.
49+type attachEvents struct {
50+ wlturbo.BaseProxy
51+ msgs chan sessionMsg
52+}
53+
54+func (p *attachEvents) Dispatch(e *wlturbo.Event) {
55+ m := sessionMsg{op: e.Opcode}
56+ if uint32(e.Opcode) != capturesession.NeferwlCaptureLayerV1EventAttached {
57+ m.numbers = append(m.numbers, int64(e.Uint32()))
58+ }
59+ p.msgs <- m
60+}
61+
62+type sessionEvents struct {
63+ wlturbo.BaseProxy
64+ msgs chan sessionMsg
65+}
66+
67+func (p *sessionEvents) Dispatch(e *wlturbo.Event) {
68+ m := sessionMsg{op: e.Opcode}
69+ switch uint32(e.Opcode) {
70+ case capturesession.NeferwlCaptureSessionV1EventState:
71+ m.token = e.String()
72+ for i := 0; i < 9; i++ {
73+ m.numbers = append(m.numbers, int64(e.Int32()))
74+ }
75+ case capturesession.NeferwlCaptureSessionV1EventStopped:
76+ m.numbers = append(m.numbers, int64(e.Uint32()))
77+ }
78+ p.msgs <- m
79+}
80+
81+type privateHarness struct {
82+ s *Server
83+ dir string
84+ events chan ports.ClientEvent
85+ commands chan ports.ClientCommand
86+ workspaces chan ports.Workspaces
87+ captures chan ports.CaptureRequest
88+ captured chan ports.CaptureDone
89+}
90+
91+func newPrivateHarness(t *testing.T) *privateHarness {
92+ t.Helper()
93+ h := &privateHarness{
94+ dir: t.TempDir(),
95+ events: make(chan ports.ClientEvent, 64),
96+ commands: make(chan ports.ClientCommand, 16),
97+ workspaces: make(chan ports.Workspaces, 4),
98+ captures: make(chan ports.CaptureRequest, 4),
99+ captured: make(chan ports.CaptureDone, 4),
100+ }
101+ out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}}
102+ s, err := New(Options{RuntimeDir: h.dir, Outputs: out}, Channels{Events: h.events, Commands: h.commands, Workspaces: h.workspaces, Captures: h.captures, Captured: h.captured}, logging.For(context.Background(), "wayland"))
103+ if err != nil {
104+ t.Fatal(err)
105+ }
106+ h.s = s
107+ ctx, cancel := context.WithCancel(context.Background())
108+ done := make(chan error, 1)
109+ go func() { done <- s.Run(ctx) }()
110+ t.Cleanup(func() {
111+ cancel()
112+ if err := <-done; err != nil {
113+ t.Errorf("Run: %v", err)
114+ }
115+ for len(h.captures) > 0 {
116+ _ = (<-h.captures).Dst.File.Close()
117+ }
118+ })
119+ return h
120+}
121+
122+type privateClient struct {
123+ c *wlturbo.Display
124+ manager uint32
125+ output uint32
126+ mgr *managerEvents
127+}
128+
129+func (h *privateHarness) client(t *testing.T) *privateClient {
130+ t.Helper()
131+ c := protocolClient(t, h.s, h.dir)
132+ g, ok := c.Registry().FindGlobal("wl_output")
133+ if !ok {
134+ t.Fatal("output missing")
135+ }
136+ out, err := c.Registry().BindID(g.Name, g.Interface, 4)
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ registerProtocol(t, c, out)
141+ pc := &privateClient{c: c, output: out, manager: bindProtocol(t, c, "neferwl_capture_manager_v1"), mgr: &managerEvents{msgs: make(chan sessionMsg, 32)}}
142+ pc.mgr.SetID(pc.manager)
143+ c.Context().Register(pc.mgr)
144+ return pc
145+}
146+
147+// begin opens a session and returns its event stream.
148+func (pc *privateClient) begin(t *testing.T, x, y, w, h int32, workspace uint64, record uint32) (uint32, chan sessionMsg) {
149+ t.Helper()
150+ id := pc.c.AllocateID()
151+ ev := &sessionEvents{msgs: make(chan sessionMsg, 32)}
152+ ev.SetID(id)
153+ pc.c.Context().Register(ev)
154+ requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, id, pc.output, x, y, w, h, uint32(workspace>>32), uint32(workspace), record)
155+ if err := pc.c.Roundtrip(); err != nil {
156+ t.Fatal(err)
157+ }
158+ return id, ev.msgs
159+}
160+
161+// waitMsg waits for a message on ch, dispatching the client meanwhile.
162+func waitMsg(t *testing.T, c *wlturbo.Display, ch chan sessionMsg) sessionMsg {
163+ t.Helper()
164+ deadline := time.Now().Add(2 * time.Second)
165+ for time.Now().Before(deadline) {
166+ select {
167+ case m := <-ch:
168+ return m
169+ default:
170+ }
171+ if err := c.Roundtrip(); err != nil {
172+ t.Fatal(err)
173+ }
174+ time.Sleep(time.Millisecond)
175+ }
176+ t.Fatal("timed out waiting for an event")
177+ return sessionMsg{}
178+}
179+
180+func noMessage(t *testing.T, c *wlturbo.Display, ch chan sessionMsg) {
181+ t.Helper()
182+ if err := c.Roundtrip(); err != nil {
183+ t.Fatal(err)
184+ }
185+ select {
186+ case m := <-ch:
187+ t.Fatalf("unexpected event %+v", m)
188+ default:
189+ }
190+}
191+
192+func nextEvent[T ports.ClientEvent](t *testing.T, ch chan ports.ClientEvent) T {
193+ t.Helper()
194+ for {
195+ select {
196+ case ev := <-ch:
197+ if v, ok := ev.(T); ok {
198+ return v
199+ }
200+ case <-time.After(2 * time.Second):
201+ t.Fatal("timed out waiting for a client event")
202+ }
203+ }
204+}
205+
206+func (h *privateHarness) send(cmd ports.ClientCommand) { h.commands <- cmd }
207+
208+func TestTrustedPeer(t *testing.T) {
209+ for _, tc := range []struct {
210+ name string
211+ peer server.Credentials
212+ err error
213+ want bool
214+ }{
215+ {"same user", server.Credentials{UID: 1000}, nil, true},
216+ {"other user", server.Credentials{UID: 1001}, nil, false},
217+ {"root is not the user", server.Credentials{UID: 0}, nil, false},
218+ {"overflow id", server.Credentials{UID: 65534}, nil, false},
219+ {"unreadable", server.Credentials{UID: 1000}, context.Canceled, false},
220+ } {
221+ if got := trustedPeer(1000, tc.peer, tc.err); got != tc.want {
222+ t.Errorf("%s: %v", tc.name, got)
223+ }
224+ }
225+}
226+
227+func TestPrivateCaptureBeginAndState(t *testing.T) {
228+ h := newPrivateHarness(t)
229+ pc := h.client(t)
230+ id, msgs := pc.begin(t, 1, 2, 3, 4, 1<<33|7, 1)
231+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
232+ if begin.Output != "HEADLESS-1" || begin.Workspace != 1<<33|7 || begin.Region != (ports.Rect{X: 1, Y: 2, W: 3, H: 4}) || !begin.Record || begin.ID == 0 {
233+ t.Fatalf("begin %+v", begin)
234+ }
235+ // Nothing is sent, and above all no token, until core confirmed.
236+ noMessage(t, pc.c, msgs)
237+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Rect: ports.Rect{X: 1, Y: 2, W: 3, H: 4}, Workspace: 1<<33 | 7, Active: true, Revision: 1<<32 | 5})
238+ m := waitMsg(t, pc.c, msgs)
239+ if uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventState || len(m.token) != 64 || strings.Trim(m.token, "0123456789abcdef") != "" {
240+ t.Fatalf("state %+v", m)
241+ }
242+ want := []int64{1, 1, 2, 3, 4, 2, 7, 1, 5}
243+ for i, v := range want {
244+ if m.numbers[i] != v {
245+ t.Fatalf("state numbers %v, want %v", m.numbers, want)
246+ }
247+ }
248+ // Ping reaches core, and a flood of them does not.
249+ for i := 0; i < 50; i++ {
250+ requestProtocol(t, pc.c, id, capturesession.NeferwlCaptureSessionV1RequestPing)
251+ }
252+ if err := pc.c.Roundtrip(); err != nil {
253+ t.Fatal(err)
254+ }
255+ if p := nextEvent[ports.CaptureSessionPing](t, h.events); p.ID != begin.ID {
256+ t.Fatalf("ping %+v", p)
257+ }
258+ select {
259+ case ev := <-h.events:
260+ if _, ok := ev.(ports.CaptureSessionPing); ok {
261+ t.Fatalf("ping flood forwarded: %+v", ev)
262+ }
263+ default:
264+ }
265+ // Destroy ends the session in core.
266+ requestProtocol(t, pc.c, id, capturesession.NeferwlCaptureSessionV1RequestDestroy)
267+ if err := pc.c.Roundtrip(); err != nil {
268+ t.Fatal(err)
269+ }
270+ if e := nextEvent[ports.CaptureSessionEnd](t, h.events); e.ID != begin.ID {
271+ t.Fatalf("end %+v", e)
272+ }
273+}
274+
275+func TestPrivateCaptureBusyAndTerminal(t *testing.T) {
276+ h := newPrivateHarness(t)
277+ pc := h.client(t)
278+ _, first := pc.begin(t, 0, 0, 0, 0, 0, 0)
279+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
280+ other := h.client(t)
281+ _, second := other.begin(t, 0, 0, 0, 0, 0, 0)
282+ if m := waitMsg(t, other.c, second); uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventStopped || m.numbers[0] != int64(capturesession.NeferwlCaptureSessionV1StopReasonBusy) {
283+ t.Fatalf("second session: %+v", m)
284+ }
285+ // The refusal neither reaches core nor disturbs the first session.
286+ noMessage(t, pc.c, first)
287+ select {
288+ case ev := <-h.events:
289+ t.Fatalf("event for a refused session: %+v", ev)
290+ default:
291+ }
292+ // A terminal state from core stops the session with its reason.
293+ h.send(ports.CaptureSessionState{ID: begin.ID, Reason: ports.CaptureReasonWorkspaceGone})
294+ if m := waitMsg(t, pc.c, first); uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventStopped || m.numbers[0] != int64(capturesession.NeferwlCaptureSessionV1StopReasonWorkspaceGone) {
295+ t.Fatalf("stopped: %+v", m)
296+ }
297+ // The slot is free again.
298+ _, third := other.begin(t, 0, 0, 0, 0, 0, 0)
299+ noMessage(t, other.c, third)
300+ nextEvent[ports.CaptureSessionBegin](t, h.events)
301+}
302+
303+func TestPrivateCaptureInvalidRequests(t *testing.T) {
304+ h := newPrivateHarness(t)
305+ pc := h.client(t)
306+ requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, pc.c.AllocateID(), pc.output, int32(0), int32(0), int32(-1), int32(4), uint32(0), uint32(0), uint32(0))
307+ expectProtocolError(t, pc.c, pc.manager, uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidRegion))
308+}
309+
310+// hud is a second connection: a layer surface it attaches with the token.
311+type hud struct {
312+ *privateClient
313+ surface, layer uint32
314+}
315+
316+func (h *privateHarness) hud(t *testing.T, layer ports.Layer, keyboard uint32) *hud {
317+ t.Helper()
318+ return h.hudVersion(t, layer, keyboard, 1)
319+}
320+
321+func (h *privateHarness) hudVersion(t *testing.T, layer ports.Layer, keyboard, shellVersion uint32) *hud {
322+ t.Helper()
323+ pc := h.client(t)
324+ comp := bindProtocol(t, pc.c, "wl_compositor")
325+ shell := bindVersion(t, pc.c, "zwlr_layer_shell_v1", shellVersion)
326+ surf, l := pc.c.AllocateID(), pc.c.AllocateID()
327+ requestProtocol(t, pc.c, comp, wayland.CompositorRequestCreateSurface, surf)
328+ registerProtocol(t, pc.c, surf)
329+ requestProtocol(t, pc.c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, l, surf, pc.output, uint32(layer), "hud")
330+ registerProtocol(t, pc.c, l)
331+ if keyboard != 0 {
332+ requestProtocol(t, pc.c, l, wlrlayershell.ZwlrLayerSurfaceV1RequestSetKeyboardInteractivity, keyboard)
333+ }
334+ if err := pc.c.Roundtrip(); err != nil {
335+ t.Fatal(err)
336+ }
337+ return &hud{privateClient: pc, surface: surf, layer: l}
338+}
339+
340+// attach sends attach_surface and returns the attachment's event stream.
341+func (hd *hud) attach(t *testing.T, token string) chan sessionMsg {
342+ t.Helper()
343+ return hd.attachSurface(t, token, hd.surface)
344+}
345+
346+func (hd *hud) attachSurface(t *testing.T, token string, surface uint32) chan sessionMsg {
347+ t.Helper()
348+ id := hd.c.AllocateID()
349+ ev := &attachEvents{msgs: make(chan sessionMsg, 8)}
350+ ev.SetID(id)
351+ hd.c.Context().Register(ev)
352+ requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, id, token, surface)
353+ if err := hd.c.Roundtrip(); err != nil {
354+ t.Fatal(err)
355+ }
356+ return ev.msgs
357+}
358+
359+// expectAttachment waits for one attachment event of the given opcode and first argument.
360+func expectAttachment(t *testing.T, c *wlturbo.Display, ch chan sessionMsg, op uint32, arg ...int64) {
361+ t.Helper()
362+ m := waitMsg(t, c, ch)
363+ if uint32(m.op) != op || (len(arg) > 0 && m.numbers[0] != arg[0]) {
364+ t.Fatalf("attachment event %+v, want op %d %v", m, op, arg)
365+ }
366+}
367+
368+// Attach works from another connection with the token, reaches core before
369+// any content, and is confirmed to the client only once core lists the layer.
370+func TestPrivateCaptureAttach(t *testing.T) {
371+ h := newPrivateHarness(t)
372+ owner := h.client(t)
373+ _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
374+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
375+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 2, H: 2}, Active: true, Revision: 1})
376+ token := waitMsg(t, owner.c, msgs).token
377+
378+ hd := h.hud(t, ports.LayerOverlay, 2)
379+ // A wrong token fails that attachment without touching the session.
380+ bad := hd.attach(t, strings.Repeat("0", 64))
381+ expectAttachment(t, hd.c, bad, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureUnknownToken))
382+ good := hd.attach(t, token)
383+ layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
384+ if layer.ID != begin.ID || !layer.Attached || layer.Layer == 0 {
385+ t.Fatalf("layer %+v", layer)
386+ }
387+ noMessage(t, hd.c, good) // not confirmed yet
388+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2})
389+ noMessage(t, hd.c, good) // a state that does not list the layer confirms nothing
390+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 3, Layers: []ports.WindowID{layer.Layer}})
391+ expectAttachment(t, hd.c, good, capturesession.NeferwlCaptureLayerV1EventAttached)
392+ // Attaching the same surface again fails on its own object.
393+ again := hd.attach(t, token)
394+ expectAttachment(t, hd.c, again, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureAlreadyAttached))
395+ // Destroying the layer detaches it: LayerChanged (the unmap) first, then
396+ // the detach, on the same ordered channel, so core never drops the
397+ // exclusion before the layer is gone.
398+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
399+ if err := hd.c.Roundtrip(); err != nil {
400+ t.Fatal(err)
401+ }
402+ expectAttachment(t, hd.c, good, capturesession.NeferwlCaptureLayerV1EventDetached, int64(capturesession.NeferwlCaptureLayerV1DetachReasonLayerDestroyed))
403+ if l := nextEvent[ports.CaptureSessionLayer](t, h.events); l.Attached || l.Layer != layer.Layer {
404+ t.Fatalf("detach %+v", l)
405+ }
406+}
407+
408+// Order on the event channel: a mapped attached layer that is destroyed
409+// queues its LayerChanged removal before the detach reaches core.
410+func TestPrivateCaptureDetachOrdering(t *testing.T) {
411+ h := newPrivateHarness(t)
412+ owner := h.client(t)
413+ _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
414+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
415+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
416+ token := waitMsg(t, owner.c, msgs).token
417+ hd := h.hud(t, ports.LayerTop, 0)
418+ att := hd.attach(t, token)
419+ layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
420+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}})
421+ expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventAttached)
422+ mapLayer(t, hd)
423+ if l := nextEvent[ports.LayerChanged](t, h.events); len(l.Layers) != 1 {
424+ t.Fatalf("mapped %+v", l)
425+ }
426+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy)
427+ if err := hd.c.Roundtrip(); err != nil {
428+ t.Fatal(err)
429+ }
430+ var order []string
431+ for len(order) < 2 {
432+ switch v := (<-h.events).(type) {
433+ case ports.LayerChanged:
434+ if len(v.Layers) == 0 {
435+ order = append(order, "unmap")
436+ }
437+ case ports.CaptureSessionLayer:
438+ if !v.Attached {
439+ order = append(order, "detach")
440+ }
441+ }
442+ }
443+ if order[0] != "unmap" || order[1] != "detach" {
444+ t.Fatalf("order %v: the exclusion must outlive the layer's last frame", order)
445+ }
446+}
447+
448+// mapLayer configures, acks and commits a buffer on the hud's layer.
449+func mapLayer(t *testing.T, hd *hud) {
450+ t.Helper()
451+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1))
452+ requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
453+ cfg := &layerProxy{configured: make(chan [3]uint32, 4)}
454+ cfg.SetID(hd.layer)
455+ hd.c.Context().Register(cfg)
456+ if err := hd.c.Roundtrip(); err != nil {
457+ t.Fatal(err)
458+ }
459+ c := <-cfg.configured
460+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, c[0])
461+ _, buf, fd := captureSmallBuffer(t, hd.c)
462+ t.Cleanup(func() { _ = unix.Close(fd) })
463+ requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
464+ requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
465+ if err := hd.c.Roundtrip(); err != nil {
466+ t.Fatal(err)
467+ }
468+}
469+
470+// Stopping the session detaches every attachment, closes the layer surfaces
471+// (so none lingers into the next session) and removes them before core is told.
472+func TestPrivateCaptureStopClosesLayers(t *testing.T) {
473+ h := newPrivateHarness(t)
474+ owner := h.client(t)
475+ sid, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
476+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
477+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
478+ token := waitMsg(t, owner.c, msgs).token
479+ hd := h.hud(t, ports.LayerOverlay, 0)
480+ closed := make(chan sessionMsg, 4)
481+ lp := &layerClosedProxy{msgs: closed}
482+ lp.SetID(hd.layer)
483+ hd.c.Context().Register(lp)
484+ confirmed := hd.attach(t, token)
485+ layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
486+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}})
487+ expectAttachment(t, hd.c, confirmed, capturesession.NeferwlCaptureLayerV1EventAttached)
488+ pending := hd.attachSurfaceNew(t, token)
489+ _ = pending
490+ requestProtocol(t, owner.c, sid, capturesession.NeferwlCaptureSessionV1RequestDestroy)
491+ if err := owner.c.Roundtrip(); err != nil {
492+ t.Fatal(err)
493+ }
494+ expectAttachment(t, hd.c, confirmed, capturesession.NeferwlCaptureLayerV1EventDetached, int64(capturesession.NeferwlCaptureLayerV1DetachReasonSessionEnded))
495+ if m := waitMsg(t, hd.c, closed); uint32(m.op) != uint32(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
496+ t.Fatalf("layer event %+v", m)
497+ }
498+ if e := nextEvent[ports.CaptureSessionEnd](t, h.events); e.ID != begin.ID {
499+ t.Fatalf("end %+v", e)
500+ }
501+}
502+
503+type layerClosedProxy struct {
504+ wlturbo.BaseProxy
505+ msgs chan sessionMsg
506+}
507+
508+func (p *layerClosedProxy) Dispatch(e *wlturbo.Event) {
509+ if e.Opcode == uint16(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
510+ p.msgs <- sessionMsg{op: e.Opcode}
511+ }
512+}
513+
514+// attachSurfaceNew attaches a second, fresh layer surface of the hud and returns its stream.
515+func (hd *hud) attachSurfaceNew(t *testing.T, token string) chan sessionMsg {
516+ t.Helper()
517+ comp := bindProtocol(t, hd.c, "wl_compositor")
518+ shell := bindProtocol(t, hd.c, "zwlr_layer_shell_v1")
519+ surf, l := hd.c.AllocateID(), hd.c.AllocateID()
520+ requestProtocol(t, hd.c, comp, wayland.CompositorRequestCreateSurface, surf)
521+ registerProtocol(t, hd.c, surf)
522+ requestProtocol(t, hd.c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, l, surf, hd.output, uint32(ports.LayerTop), "hud2")
523+ registerProtocol(t, hd.c, l)
524+ return hd.attachSurface(t, token, surf)
525+}
526+
527+func TestPrivateCaptureAttachRefusals(t *testing.T) {
528+ for _, tc := range []struct {
529+ name string
530+ layer ports.Layer
531+ keyboard uint32
532+ code uint32
533+ }{
534+ {"background layer", ports.LayerBackground, 0, uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidLayer)},
535+ {"bottom layer", ports.LayerBottom, 0, uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidLayer)},
536+ {"exclusive keyboard", ports.LayerTop, 1, uint32(capturesession.NeferwlCaptureManagerV1ErrorExclusiveKeyboard)},
537+ } {
538+ t.Run(tc.name, func(t *testing.T) {
539+ h := newPrivateHarness(t)
540+ owner := h.client(t)
541+ _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
542+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
543+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
544+ token := waitMsg(t, owner.c, msgs).token
545+ hd := h.hud(t, tc.layer, tc.keyboard)
546+ requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, hd.c.AllocateID(), token, hd.surface)
547+ expectProtocolError(t, hd.c, hd.manager, tc.code)
548+ select {
549+ case ev := <-h.events:
550+ if _, ok := ev.(ports.CaptureSessionLayer); ok {
551+ t.Fatalf("a refused attach reached core: %+v", ev)
552+ }
553+ default:
554+ }
555+ })
556+ }
557+}
558+
559+func TestPrivateCaptureAttachLimit(t *testing.T) {
560+ h := newPrivateHarness(t)
561+ owner := h.client(t)
562+ _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
563+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
564+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
565+ token := waitMsg(t, owner.c, msgs).token
566+ hd := h.hud(t, ports.LayerTop, 0)
567+ hd.attach(t, token)
568+ for i := 1; i < ports.MaxCaptureSessionLayers; i++ {
569+ hd.attachSurfaceNew(t, token)
570+ }
571+ over := hd.attachSurfaceNew(t, token)
572+ expectAttachment(t, hd.c, over, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureTooManyLayers))
573+ n := 0
574+ for len(h.events) > 0 {
575+ if l, ok := (<-h.events).(ports.CaptureSessionLayer); ok && l.Attached {
576+ n++
577+ }
578+ }
579+ if n != ports.MaxCaptureSessionLayers {
580+ t.Fatalf("%d layers reached core, want %d", n, ports.MaxCaptureSessionLayers)
581+ }
582+}
583+
584+func TestPrivateCaptureWorkspaceInventory(t *testing.T) {
585+ h := newPrivateHarness(t)
586+ pc := h.client(t)
587+ h.workspaces <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 1<<32 | 3, Name: "dev", Active: true, Frame: ports.Rect{X: 5, Y: 6, W: 7, H: 8}}, {ID: 4, Name: "2"}}}}}
588+ seen := map[uint64]sessionMsg{}
589+ for len(seen) < 2 {
590+ m := waitMsg(t, pc.c, pc.mgr.msgs)
591+ if uint32(m.op) != capturesession.NeferwlCaptureManagerV1EventWorkspace {
592+ t.Fatalf("event %+v", m)
593+ }
594+ seen[uint64(m.numbers[0])<<32|uint64(m.numbers[1])] = m
595+ }
596+ if m := seen[1<<32|3]; m.name != "dev" || m.numbers[2] != 5 || m.numbers[3] != 6 || m.numbers[4] != 7 || m.numbers[5] != 8 || m.numbers[6] != 1 {
597+ t.Fatalf("workspace %+v", m)
598+ }
599+ h.workspaces <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 4, Name: "2"}}}}}
600+ if m := waitMsg(t, pc.c, pc.mgr.msgs); uint32(m.op) != capturesession.NeferwlCaptureManagerV1EventWorkspaceRemoved || m.numbers[0] != 1 || m.numbers[1] != 3 {
601+ t.Fatalf("removed %+v", m)
602+ }
603+}
604+
605+// captureOnce asks a wlr capture of the whole 2x2 output on client a.
606+func captureOnce(t *testing.T, h *privateHarness, pc *privateClient) (*captureEvents, uint32) {
607+ t.Helper()
608+ _, buf, fd := captureSmallBuffer(t, pc.c)
609+ t.Cleanup(func() { _ = unix.Close(fd) })
610+ mgr := bindVersion(t, pc.c, "zwlr_screencopy_manager_v1", 3)
611+ frame := pc.c.AllocateID()
612+ ev := &captureEvents{events: make(chan uint16, 16)}
613+ ev.SetID(frame)
614+ pc.c.Context().Register(ev)
615+ requestProtocol(t, pc.c, mgr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), pc.output)
616+ requestProtocol(t, pc.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
617+ if err := pc.c.Roundtrip(); err != nil {
618+ t.Fatal(err)
619+ }
620+ return ev, frame
621+}
622+
623+// The owner's captures are tagged clean and fenced by the confirmed
624+// revision; before confirmation or while paused they are refused, not served
625+// with the HUD in them. Every other connection is untouched.
626+func TestPrivateCaptureCleanTagging(t *testing.T) {
627+ h := newPrivateHarness(t)
628+ owner, other := h.client(t), h.client(t)
629+ _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
630+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
631+
632+ // Unconfirmed: the owner's capture fails and never reaches the renderer.
633+ ev, _ := captureOnce(t, h, owner)
634+ if !eventsInclude(ev, uint16(wlr.ZwlrScreencopyFrameV1EventFailed)) {
635+ t.Fatal("the capture of an unconfirmed session did not fail")
636+ }
637+ select {
638+ case r := <-h.captures:
639+ _ = r.Dst.File.Close()
640+ t.Fatalf("an unconfirmed session's capture reached the renderer: %+v", r)
641+ default:
642+ }
643+
644+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 9})
645+ waitMsg(t, owner.c, msgs)
646+ captureOnce(t, h, owner)
647+ r := receive1(t, h.captures)
648+ if !r.Clean || r.Session != begin.ID || r.CaptureRevision != 9 {
649+ t.Fatalf("owner capture %+v", r)
650+ }
651+ _ = r.Dst.File.Close()
652+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
653+
654+ captureOnce(t, h, other)
655+ r = receive1(t, h.captures)
656+ if r.Clean || r.Session != 0 || r.CaptureRevision != 0 {
657+ t.Fatalf("another connection's capture tagged: %+v", r)
658+ }
659+ _ = r.Dst.File.Close()
660+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
661+
662+ // Paused: the owner is refused again.
663+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: false, Revision: 10})
664+ waitMsg(t, owner.c, msgs)
665+ ev, _ = captureOnce(t, h, owner)
666+ if !eventsInclude(ev, uint16(wlr.ZwlrScreencopyFrameV1EventFailed)) {
667+ t.Fatal("the capture of a paused session did not fail")
668+ }
669+ select {
670+ case r := <-h.captures:
671+ _ = r.Dst.File.Close()
672+ t.Fatalf("a paused session's capture reached the renderer: %+v", r)
673+ default:
674+ }
675+}
676+
677+// eventsInclude drains the frame events received so far and reports op among them.
678+func eventsInclude(ev *captureEvents, op uint16) bool {
679+ found := false
680+ for len(ev.events) > 0 {
681+ if <-ev.events == op {
682+ found = true
683+ }
684+ }
685+ return found
686+}
687+
688+func receive1(t *testing.T, ch chan ports.CaptureRequest) ports.CaptureRequest {
689+ t.Helper()
690+ select {
691+ case r := <-ch:
692+ return r
693+ case <-time.After(2 * time.Second):
694+ t.Fatal("no capture request")
695+ return ports.CaptureRequest{}
696+ }
697+}
698+
699+// After the session ended and until the owner destroys the object, its
700+// captures of the output are refused, never served unfiltered; another
701+// connection is unaffected.
702+func TestPrivateCaptureOwnerRefusedAfterStop(t *testing.T) {
703+ h := newPrivateHarness(t)
704+ owner, other := h.client(t), h.client(t)
705+ sid, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
706+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
707+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
708+ waitMsg(t, owner.c, msgs)
709+ h.send(ports.CaptureSessionState{ID: begin.ID, Reason: ports.CaptureReasonOutputOff})
710+ if m := waitMsg(t, owner.c, msgs); uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventStopped {
711+ t.Fatalf("stopped: %+v", m)
712+ }
713+ ev, _ := captureOnce(t, h, owner)
714+ if !eventsInclude(ev, uint16(wlr.ZwlrScreencopyFrameV1EventFailed)) {
715+ t.Fatal("the owner's capture after the stop did not fail")
716+ }
717+ select {
718+ case r := <-h.captures:
719+ _ = r.Dst.File.Close()
720+ t.Fatalf("a stopped session's capture reached the renderer: %+v", r)
721+ default:
722+ }
723+ captureOnce(t, h, other)
724+ r := receive1(t, h.captures)
725+ if r.Clean {
726+ t.Fatalf("another connection's capture is clean: %+v", r)
727+ }
728+ _ = r.Dst.File.Close()
729+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
730+ // Destroying the object lifts the refusal.
731+ requestProtocol(t, owner.c, sid, capturesession.NeferwlCaptureSessionV1RequestDestroy)
732+ if err := owner.c.Roundtrip(); err != nil {
733+ t.Fatal(err)
734+ }
735+ captureOnce(t, h, owner)
736+ r = receive1(t, h.captures)
737+ if r.Clean {
738+ t.Fatalf("capture after destroy: %+v", r)
739+ }
740+ _ = r.Dst.File.Close()
741+}
742+
743+// An attached layer that commits a layer below top is closed and detached
744+// once, and its unmap reaches core before the detach.
745+func TestPrivateCaptureLayerMovedBelowTop(t *testing.T) {
746+ h := newPrivateHarness(t)
747+ owner := h.client(t)
748+ _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
749+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
750+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
751+ token := waitMsg(t, owner.c, msgs).token
752+ hd := h.hudVersion(t, ports.LayerTop, 0, 2)
753+ att := hd.attach(t, token)
754+ layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
755+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}})
756+ expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventAttached)
757+ mapLayer(t, hd)
758+ if l := nextEvent[ports.LayerChanged](t, h.events); len(l.Layers) != 1 {
759+ t.Fatalf("mapped %+v", l)
760+ }
761+ closed := make(chan sessionMsg, 4)
762+ lp := &layerClosedProxy{msgs: closed}
763+ lp.SetID(hd.layer)
764+ hd.c.Context().Register(lp)
765+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetLayer, uint32(ports.LayerBottom))
766+ requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit)
767+ if err := hd.c.Roundtrip(); err != nil {
768+ t.Fatal(err)
769+ }
770+ expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventDetached, int64(capturesession.NeferwlCaptureLayerV1DetachReasonLayerDestroyed))
771+ if m := waitMsg(t, hd.c, closed); uint32(m.op) != uint32(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) {
772+ t.Fatalf("layer event %+v", m)
773+ }
774+ noMessage(t, hd.c, att) // detached exactly once
775+ var order []string
776+ for len(order) < 2 {
777+ switch v := (<-h.events).(type) {
778+ case ports.LayerChanged:
779+ if len(v.Layers) == 0 {
780+ order = append(order, "unmap")
781+ }
782+ case ports.CaptureSessionLayer:
783+ if !v.Attached {
784+ order = append(order, "detach")
785+ }
786+ }
787+ }
788+ if order[0] != "unmap" || order[1] != "detach" {
789+ t.Fatalf("order %v", order)
790+ }
791+}
792+
793+// An attached layer asking for keyboard interactivity, before or after the
794+// attach, is never reported with it to core.
795+func TestPrivateCaptureLayerKeyboardNone(t *testing.T) {
796+ h := newPrivateHarness(t)
797+ owner := h.client(t)
798+ _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0)
799+ begin := nextEvent[ports.CaptureSessionBegin](t, h.events)
800+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1})
801+ token := waitMsg(t, owner.c, msgs).token
802+ hd := h.hud(t, ports.LayerOverlay, 2) // on demand before the attach
803+ att := hd.attach(t, token)
804+ layer := nextEvent[ports.CaptureSessionLayer](t, h.events)
805+ h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}})
806+ expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventAttached)
807+ requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetKeyboardInteractivity, uint32(2)) // and after
808+ mapLayer(t, hd)
809+ l := nextEvent[ports.LayerChanged](t, h.events)
810+ if len(l.Layers) != 1 || l.Layers[0].Keyboard != 0 {
811+ t.Fatalf("layer reported with keyboard: %+v", l.Layers)
812+ }
813+}
A internal/adapters/wayland/capturesession/capturesession.go +296 -0
@@ -0,0 +1,296 @@
1+// Code generated by wlgen; DO NOT EDIT.
2+package capturesession
3+
4+import (
5+ "fmt"
6+ wayland "github.com/bnema/purego-libwayland/protocol/wayland"
7+ "github.com/bnema/purego-libwayland/server"
8+ "runtime"
9+)
10+
11+var NeferwlCaptureManagerV1Interface *server.Interface
12+var NeferwlCaptureLayerV1Interface *server.Interface
13+var NeferwlCaptureSessionV1Interface *server.Interface
14+
15+func init() {
16+ NeferwlCaptureManagerV1Interface = &server.Interface{Name: "neferwl_capture_manager_v1", Version: 1}
17+ NeferwlCaptureLayerV1Interface = &server.Interface{Name: "neferwl_capture_layer_v1", Version: 1}
18+ NeferwlCaptureSessionV1Interface = &server.Interface{Name: "neferwl_capture_session_v1", Version: 1}
19+ NeferwlCaptureManagerV1Interface.Requests = []server.Message{
20+ {Name: "destroy", Signature: "", Types: []*server.Interface{}},
21+ {Name: "begin_session", Signature: "noiiiiuuu", Types: []*server.Interface{NeferwlCaptureSessionV1Interface, wayland.OutputInterface, nil, nil, nil, nil, nil, nil, nil}},
22+ {Name: "attach_surface", Signature: "nso", Types: []*server.Interface{NeferwlCaptureLayerV1Interface, nil, wayland.SurfaceInterface}},
23+ }
24+ NeferwlCaptureManagerV1Interface.Events = []server.Message{
25+ {Name: "workspace", Signature: "uussiiiiu", Types: []*server.Interface{nil, nil, nil, nil, nil, nil, nil, nil, nil}},
26+ {Name: "workspace_removed", Signature: "uu", Types: []*server.Interface{nil, nil}},
27+ }
28+ NeferwlCaptureLayerV1Interface.Requests = []server.Message{
29+ {Name: "destroy", Signature: "", Types: []*server.Interface{}},
30+ }
31+ NeferwlCaptureLayerV1Interface.Events = []server.Message{
32+ {Name: "attached", Signature: "", Types: []*server.Interface{}},
33+ {Name: "failed", Signature: "u", Types: []*server.Interface{nil}},
34+ {Name: "detached", Signature: "u", Types: []*server.Interface{nil}},
35+ }
36+ NeferwlCaptureSessionV1Interface.Requests = []server.Message{
37+ {Name: "destroy", Signature: "", Types: []*server.Interface{}},
38+ {Name: "ping", Signature: "", Types: []*server.Interface{}},
39+ }
40+ NeferwlCaptureSessionV1Interface.Events = []server.Message{
41+ {Name: "state", Signature: "suiiiiuuuu", Types: []*server.Interface{nil, nil, nil, nil, nil, nil, nil, nil, nil, nil}},
42+ {Name: "stopped", Signature: "u", Types: []*server.Interface{nil}},
43+ }
44+ if err := server.NewInterfaces(NeferwlCaptureManagerV1Interface, NeferwlCaptureLayerV1Interface, NeferwlCaptureSessionV1Interface); err != nil {
45+ panic(err)
46+ }
47+}
48+
49+// NeferwlCaptureManagerV1 private capture manager.
50+type NeferwlCaptureManagerV1 struct{ *server.Resource }
51+
52+func WrapNeferwlCaptureManagerV1(r *server.Resource) *NeferwlCaptureManagerV1 {
53+ if r == nil {
54+ return nil
55+ }
56+ return &NeferwlCaptureManagerV1{r}
57+}
58+
59+const NeferwlCaptureManagerV1RequestDestroy uint32 = 0
60+const NeferwlCaptureManagerV1RequestBeginSession uint32 = 1
61+const NeferwlCaptureManagerV1RequestAttachSurface uint32 = 2
62+const NeferwlCaptureManagerV1EventWorkspace uint32 = 0
63+const NeferwlCaptureManagerV1EventWorkspaceRemoved uint32 = 1
64+
65+// NeferwlCaptureManagerV1Error .
66+type NeferwlCaptureManagerV1Error uint32
67+
68+const (
69+ NeferwlCaptureManagerV1ErrorInvalidOutput NeferwlCaptureManagerV1Error = 0
70+ NeferwlCaptureManagerV1ErrorInvalidRegion NeferwlCaptureManagerV1Error = 1
71+ NeferwlCaptureManagerV1ErrorSurfaceRole NeferwlCaptureManagerV1Error = 2
72+ NeferwlCaptureManagerV1ErrorInvalidLayer NeferwlCaptureManagerV1Error = 3
73+ NeferwlCaptureManagerV1ErrorExclusiveKeyboard NeferwlCaptureManagerV1Error = 4
74+ NeferwlCaptureManagerV1ErrorInvalidSurface NeferwlCaptureManagerV1Error = 5
75+)
76+
77+// NeferwlCaptureManagerV1Handler handles neferwl_capture_manager_v1 requests.
78+type NeferwlCaptureManagerV1Handler interface {
79+ Destroy(self *NeferwlCaptureManagerV1)
80+ BeginSession(self *NeferwlCaptureManagerV1, id uint32, output *wayland.Output, x int32, y int32, width int32, height int32, workspaceHi uint32, workspaceLo uint32, record uint32)
81+ AttachSurface(self *NeferwlCaptureManagerV1, id uint32, token string, surface *wayland.Surface)
82+}
83+
84+func NewNeferwlCaptureManagerV1(c server.Client, version int32, id uint32, h NeferwlCaptureManagerV1Handler) (*NeferwlCaptureManagerV1, error) {
85+ if h == nil {
86+ return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_manager_v1")
87+ }
88+ var self *NeferwlCaptureManagerV1
89+ r, err := c.CreateResource(NeferwlCaptureManagerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
90+ switch opcode {
91+ case 0:
92+ h.Destroy(self)
93+ self.Destroy()
94+ case 1:
95+ h.BeginSession(self, args[0].NewID(), wayland.WrapOutput(args[1].Resource()), args[2].Int(), args[3].Int(), args[4].Int(), args[5].Int(), args[6].Uint(), args[7].Uint(), args[8].Uint())
96+ case 2:
97+ h.AttachSurface(self, args[0].NewID(), args[1].String(), wayland.WrapSurface(args[2].Resource()))
98+ }
99+ })
100+ if err != nil {
101+ return nil, err
102+ }
103+ self = WrapNeferwlCaptureManagerV1(r)
104+ r.Data = self
105+ return self, nil
106+}
107+
108+// SendWorkspace a workspace and its geometry.
109+func (self *NeferwlCaptureManagerV1) SendWorkspace(workspaceHi uint32, workspaceLo uint32, outputName string, name string, x int32, y int32, width int32, height int32, active uint32) {
110+ var p runtime.Pinner
111+ defer p.Unpin()
112+ self.PostEvent(NeferwlCaptureManagerV1EventWorkspace, server.Uint(workspaceHi), server.Uint(workspaceLo), server.String(&p, outputName, false), server.String(&p, name, false), server.Int(x), server.Int(y), server.Int(width), server.Int(height), server.Uint(active))
113+}
114+
115+// SendWorkspaceRemoved a workspace is gone.
116+func (self *NeferwlCaptureManagerV1) SendWorkspaceRemoved(workspaceHi uint32, workspaceLo uint32) {
117+ self.PostEvent(NeferwlCaptureManagerV1EventWorkspaceRemoved, server.Uint(workspaceHi), server.Uint(workspaceLo))
118+}
119+func NewNeferwlCaptureManagerV1Global(d *server.Display, version int32, bind server.BindFunc) error {
120+ return d.CreateGlobal(NeferwlCaptureManagerV1Interface, version, bind)
121+}
122+
123+// NeferwlCaptureLayerV1 a layer surface attached to a capture session.
124+type NeferwlCaptureLayerV1 struct{ *server.Resource }
125+
126+func WrapNeferwlCaptureLayerV1(r *server.Resource) *NeferwlCaptureLayerV1 {
127+ if r == nil {
128+ return nil
129+ }
130+ return &NeferwlCaptureLayerV1{r}
131+}
132+
133+const NeferwlCaptureLayerV1RequestDestroy uint32 = 0
134+const NeferwlCaptureLayerV1EventAttached uint32 = 0
135+const NeferwlCaptureLayerV1EventFailed uint32 = 1
136+const NeferwlCaptureLayerV1EventDetached uint32 = 2
137+
138+// NeferwlCaptureLayerV1Failure .
139+type NeferwlCaptureLayerV1Failure uint32
140+
141+const (
142+ NeferwlCaptureLayerV1FailureUnknownToken NeferwlCaptureLayerV1Failure = 0
143+ NeferwlCaptureLayerV1FailureUnauthorized NeferwlCaptureLayerV1Failure = 1
144+ NeferwlCaptureLayerV1FailureTooManyLayers NeferwlCaptureLayerV1Failure = 2
145+ NeferwlCaptureLayerV1FailureAlreadyAttached NeferwlCaptureLayerV1Failure = 3
146+ NeferwlCaptureLayerV1FailureSessionEnded NeferwlCaptureLayerV1Failure = 4
147+ NeferwlCaptureLayerV1FailureLayerDestroyed NeferwlCaptureLayerV1Failure = 5
148+)
149+
150+// NeferwlCaptureLayerV1DetachReason .
151+type NeferwlCaptureLayerV1DetachReason uint32
152+
153+const (
154+ NeferwlCaptureLayerV1DetachReasonSessionEnded NeferwlCaptureLayerV1DetachReason = 0
155+ NeferwlCaptureLayerV1DetachReasonLayerDestroyed NeferwlCaptureLayerV1DetachReason = 1
156+)
157+
158+// NeferwlCaptureLayerV1Handler handles neferwl_capture_layer_v1 requests.
159+type NeferwlCaptureLayerV1Handler interface {
160+ Destroy(self *NeferwlCaptureLayerV1)
161+}
162+
163+func NewNeferwlCaptureLayerV1(c server.Client, version int32, id uint32, h NeferwlCaptureLayerV1Handler) (*NeferwlCaptureLayerV1, error) {
164+ if h == nil {
165+ return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_layer_v1")
166+ }
167+ var self *NeferwlCaptureLayerV1
168+ r, err := c.CreateResource(NeferwlCaptureLayerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
169+ switch opcode {
170+ case 0:
171+ h.Destroy(self)
172+ self.Destroy()
173+ }
174+ })
175+ if err != nil {
176+ return nil, err
177+ }
178+ self = WrapNeferwlCaptureLayerV1(r)
179+ r.Data = self
180+ return self, nil
181+}
182+
183+// SendAttached the layer is part of the session.
184+func (self *NeferwlCaptureLayerV1) SendAttached() {
185+ self.PostEvent(NeferwlCaptureLayerV1EventAttached)
186+}
187+
188+// SendFailed the attachment was refused.
189+func (self *NeferwlCaptureLayerV1) SendFailed(reason uint32) {
190+ self.PostEvent(NeferwlCaptureLayerV1EventFailed, server.Uint(reason))
191+}
192+
193+// SendDetached the layer left the session.
194+func (self *NeferwlCaptureLayerV1) SendDetached(reason uint32) {
195+ self.PostEvent(NeferwlCaptureLayerV1EventDetached, server.Uint(reason))
196+}
197+func NewNeferwlCaptureLayerV1Global(d *server.Display, version int32, bind server.BindFunc) error {
198+ return d.CreateGlobal(NeferwlCaptureLayerV1Interface, version, bind)
199+}
200+
201+// NeferwlCaptureSessionV1 one private capture session.
202+type NeferwlCaptureSessionV1 struct{ *server.Resource }
203+
204+func WrapNeferwlCaptureSessionV1(r *server.Resource) *NeferwlCaptureSessionV1 {
205+ if r == nil {
206+ return nil
207+ }
208+ return &NeferwlCaptureSessionV1{r}
209+}
210+
211+const NeferwlCaptureSessionV1RequestDestroy uint32 = 0
212+const NeferwlCaptureSessionV1RequestPing uint32 = 1
213+const NeferwlCaptureSessionV1EventState uint32 = 0
214+const NeferwlCaptureSessionV1EventStopped uint32 = 1
215+
216+// NeferwlCaptureSessionV1StopReason .
217+type NeferwlCaptureSessionV1StopReason uint32
218+
219+const (
220+ NeferwlCaptureSessionV1StopReasonRequested NeferwlCaptureSessionV1StopReason = 0
221+ NeferwlCaptureSessionV1StopReasonOutputGone NeferwlCaptureSessionV1StopReason = 1
222+ NeferwlCaptureSessionV1StopReasonOutputOff NeferwlCaptureSessionV1StopReason = 2
223+ NeferwlCaptureSessionV1StopReasonPingTimeout NeferwlCaptureSessionV1StopReason = 3
224+ NeferwlCaptureSessionV1StopReasonWorkspaceGone NeferwlCaptureSessionV1StopReason = 5
225+ NeferwlCaptureSessionV1StopReasonBusy NeferwlCaptureSessionV1StopReason = 6
226+ NeferwlCaptureSessionV1StopReasonInvalidRegion NeferwlCaptureSessionV1StopReason = 7
227+ NeferwlCaptureSessionV1StopReasonUnauthorized NeferwlCaptureSessionV1StopReason = 8
228+ NeferwlCaptureSessionV1StopReasonTooManyExcluded NeferwlCaptureSessionV1StopReason = 9
229+)
230+
231+// NeferwlCaptureSessionV1Handler handles neferwl_capture_session_v1 requests.
232+type NeferwlCaptureSessionV1Handler interface {
233+ Destroy(self *NeferwlCaptureSessionV1)
234+ Ping(self *NeferwlCaptureSessionV1)
235+}
236+
237+func NewNeferwlCaptureSessionV1(c server.Client, version int32, id uint32, h NeferwlCaptureSessionV1Handler) (*NeferwlCaptureSessionV1, error) {
238+ if h == nil {
239+ return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_session_v1")
240+ }
241+ var self *NeferwlCaptureSessionV1
242+ r, err := c.CreateResource(NeferwlCaptureSessionV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) {
243+ switch opcode {
244+ case 0:
245+ h.Destroy(self)
246+ self.Destroy()
247+ case 1:
248+ h.Ping(self)
249+ }
250+ })
251+ if err != nil {
252+ return nil, err
253+ }
254+ self = WrapNeferwlCaptureSessionV1(r)
255+ r.Data = self
256+ return self, nil
257+}
258+
259+// SendState what the compositor decided.
260+func (self *NeferwlCaptureSessionV1) SendState(token string, active uint32, x int32, y int32, width int32, height int32, workspaceHi uint32, workspaceLo uint32, revisionHi uint32, revisionLo uint32) {
261+ var p runtime.Pinner
262+ defer p.Unpin()
263+ self.PostEvent(NeferwlCaptureSessionV1EventState, server.String(&p, token, false), server.Uint(active), server.Int(x), server.Int(y), server.Int(width), server.Int(height), server.Uint(workspaceHi), server.Uint(workspaceLo), server.Uint(revisionHi), server.Uint(revisionLo))
264+}
265+
266+// SendStopped the session ended.
267+func (self *NeferwlCaptureSessionV1) SendStopped(reason uint32) {
268+ self.PostEvent(NeferwlCaptureSessionV1EventStopped, server.Uint(reason))
269+}
270+func NewNeferwlCaptureSessionV1Global(d *server.Display, version int32, bind server.BindFunc) error {
271+ return d.CreateGlobal(NeferwlCaptureSessionV1Interface, version, bind)
272+}
273+func resourceOf(v interface{ GetResource() *server.Resource }) *server.Resource {
274+ if v == nil {
275+ return nil
276+ }
277+ return v.GetResource()
278+}
279+func (self *NeferwlCaptureManagerV1) GetResource() *server.Resource {
280+ if self == nil {
281+ return nil
282+ }
283+ return self.Resource
284+}
285+func (self *NeferwlCaptureLayerV1) GetResource() *server.Resource {
286+ if self == nil {
287+ return nil
288+ }
289+ return self.Resource
290+}
291+func (self *NeferwlCaptureSessionV1) GetResource() *server.Resource {
292+ if self == nil {
293+ return nil
294+ }
295+ return self.Resource
296+}
A internal/adapters/wayland/capturesession/gen.sh +9 -0
@@ -0,0 +1,9 @@
1+#!/bin/sh
2+# Regenerates capturesession.go from neferwl-capture-v1.xml with the wlgen of
3+# the purego-libwayland module in use (offline: go.work or the module cache).
4+set -eu
5+cd "$(dirname "$0")"
6+mod=$(go list -m -f '{{.Dir}}' github.com/bnema/purego-libwayland)
7+go run github.com/bnema/purego-libwayland/cmd/wlgen -package capturesession -out capturesession.go \
8+ -import wayland=github.com/bnema/purego-libwayland/protocol/wayland -import-xml wayland="$mod/protocols/wayland.xml" \
9+ neferwl-capture-v1.xml
A internal/adapters/wayland/capturesession/neferwl-capture-v1.xml +236 -0
@@ -0,0 +1,236 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<protocol name="neferwl_capture_v1">
3+ <copyright>
4+ neferwl private protocol. Not for other compositors; no stability promise
5+ beyond the version of the interface.
6+ </copyright>
7+
8+ <description summary="private capture sessions for the neferwl screen recorder">
9+ Lets one client (the capture connection) open a session on a workspace and
10+ lets a second connection of the same user (the HUD) attach layer surfaces
11+ to it. While the session is active, the attached surfaces stay visible
12+ over a fullscreen window and are left out of the captures the capture
13+ connection takes (ext-image-copy-capture / wlr-screencopy on the same
14+ connection). Every other client's captures still show them.
15+
16+ Only one session is live in the compositor. The session token is a secret:
17+ it is sent only to the session's owner and must never be logged.
18+ Only the compositor's own user may use this protocol.
19+ </description>
20+
21+ <interface name="neferwl_capture_manager_v1" version="1">
22+ <description summary="private capture manager">
23+ Global. Binding it starts the workspace inventory: one workspace event
24+ per workspace, then workspace events again for every change and
25+ workspace_removed for each workspace that disappears.
26+ </description>
27+
28+ <enum name="error">
29+ <entry name="invalid_output" value="0" summary="the wl_output is unknown or gone"/>
30+ <entry name="invalid_region" value="1" summary="negative width or height"/>
31+ <entry name="surface_role" value="2" summary="the surface is not a wlr-layer-shell surface"/>
32+ <entry name="invalid_layer" value="3" summary="the layer is neither top nor overlay"/>
33+ <entry name="exclusive_keyboard" value="4" summary="the layer takes the keyboard exclusively"/>
34+ <entry name="invalid_surface" value="5" summary="the surface belongs to another client or is already mapped"/>
35+ </enum>
36+
37+ <request name="destroy" type="destructor">
38+ <description summary="destroy the manager">
39+ Sessions created through it stay alive.
40+ </description>
41+ </request>
42+
43+ <request name="begin_session">
44+ <description summary="open a capture session">
45+ Asks for a session. Workspace 0 (both halves) targets the output:
46+ whatever it shows, bars and margins included, and the overview too.
47+ A non-zero workspace ID (from a workspace event) targets that
48+ workspace's frame, without bars, whether or not it is on screen and
49+ whatever the overview shows. The session is stopped with
50+ workspace_gone when the workspace is removed.
51+
52+ The region is in output-local logical pixels, clipped to the output
53+ (workspace 0) or to the workspace frame. A zero width or height means
54+ all of it. Record non-zero asks for the native recording border, drawn
55+ inside the target's edge and never in this connection's clean captures.
56+
57+ A served session answers with state, which carries the token. A
58+ session that cannot be served gets stopped with the reason, without a
59+ state. Only one session is live: a second one is stopped with reason
60+ busy.
61+ </description>
62+ <arg name="id" type="new_id" interface="neferwl_capture_session_v1"/>
63+ <arg name="output" type="object" interface="wl_output"/>
64+ <arg name="x" type="int"/>
65+ <arg name="y" type="int"/>
66+ <arg name="width" type="int"/>
67+ <arg name="height" type="int"/>
68+ <arg name="workspace_hi" type="uint"/>
69+ <arg name="workspace_lo" type="uint"/>
70+ <arg name="record" type="uint"/>
71+ </request>
72+
73+ <request name="attach_surface">
74+ <description summary="attach a layer surface to a session by token">
75+ Marks a layer surface of this client as part of the session that owns
76+ the token, and creates the attachment object that reports the result.
77+ The client is usually not the session's owner but has the same user
78+ ID; both are checked with the peer credentials. The surface must
79+ already have the wlr-layer-shell role, on the top or overlay layer,
80+ must not have mapped yet and must not have asked for exclusive keyboard
81+ interactivity. At most 4 layers per session. Once attached, a layer
82+ never gets keyboard interactivity, whatever it asks for, and if it later moves to another layer the compositor closes it
83+ and sends detached.
84+
85+ The attachment answers with exactly one of attached or failed. The
86+ surface is excluded from clean captures from this request on, before
87+ it can map; attached only says that the compositor core confirmed it,
88+ and the client must not show the surface before it. A refusal is
89+ failed, not a protocol error, except the errors above.
90+
91+ The attachment ends with detached when the session stops or the layer
92+ surface is destroyed. Destroying the attachment object does not
93+ release the surface: it stays excluded until its layer role is
94+ destroyed or the session stops.
95+ </description>
96+ <arg name="id" type="new_id" interface="neferwl_capture_layer_v1"/>
97+ <arg name="token" type="string"/>
98+ <arg name="surface" type="object" interface="wl_surface"/>
99+ </request>
100+
101+ <event name="workspace">
102+ <description summary="a workspace and its geometry">
103+ A workspace of the inventory, new or changed. The rectangle is the
104+ workspace frame in output-local logical pixels. Active is non-zero for
105+ the workspace on screen. Name is the workspace name or its number.
106+ </description>
107+ <arg name="workspace_hi" type="uint"/>
108+ <arg name="workspace_lo" type="uint"/>
109+ <arg name="output_name" type="string"/>
110+ <arg name="name" type="string"/>
111+ <arg name="x" type="int"/>
112+ <arg name="y" type="int"/>
113+ <arg name="width" type="int"/>
114+ <arg name="height" type="int"/>
115+ <arg name="active" type="uint"/>
116+ </event>
117+
118+ <event name="workspace_removed">
119+ <description summary="a workspace is gone"/>
120+ <arg name="workspace_hi" type="uint"/>
121+ <arg name="workspace_lo" type="uint"/>
122+ </event>
123+ </interface>
124+
125+ <interface name="neferwl_capture_layer_v1" version="1">
126+ <description summary="a layer surface attached to a capture session">
127+ Created by attach_surface. Exactly one of attached or failed comes
128+ first; detached comes at most once, after attached.
129+ </description>
130+
131+ <enum name="failure">
132+ <entry name="unknown_token" value="0" summary="no live session has this token"/>
133+ <entry name="unauthorized" value="1" summary="peer credentials differ from the compositor user's or are unreadable"/>
134+ <entry name="too_many_layers" value="2" summary="the session already has the maximum of 4 attached layers"/>
135+ <entry name="already_attached" value="3" summary="the surface is already attached"/>
136+ <entry name="session_ended" value="4" summary="the session stopped before core confirmed the layer"/>
137+ <entry name="layer_destroyed" value="5" summary="the layer role was destroyed before core confirmed it"/>
138+ </enum>
139+
140+ <enum name="detach_reason">
141+ <entry name="session_ended" value="0" summary="the session stopped; the compositor closed the layer surface"/>
142+ <entry name="layer_destroyed" value="1" summary="the layer role was destroyed"/>
143+ </enum>
144+
145+ <request name="destroy" type="destructor">
146+ <description summary="destroy the attachment object">
147+ The surface stays excluded from clean captures until its layer role
148+ is destroyed or the session stops.
149+ </description>
150+ </request>
151+
152+ <event name="attached">
153+ <description summary="the layer is part of the session">
154+ The compositor core confirmed the attachment: the layer is excluded
155+ from the session's clean captures, and was already before it could map.
156+ Safe to show.
157+ </description>
158+ </event>
159+
160+ <event name="failed">
161+ <description summary="the attachment was refused"/>
162+ <arg name="reason" type="uint" enum="failure"/>
163+ </event>
164+
165+ <event name="detached">
166+ <description summary="the layer left the session">
167+ Sent once after attached. When the session stopped, the compositor
168+ also closed the layer surface (zwlr_layer_surface_v1.closed): the
169+ client destroys it.
170+ </description>
171+ <arg name="reason" type="uint" enum="detach_reason"/>
172+ </event>
173+ </interface>
174+
175+ <interface name="neferwl_capture_session_v1" version="1">
176+ <description summary="one private capture session">
177+ Destroying the session stops it. A session whose owner sends no ping for
178+ 5 seconds is stopped by the compositor.
179+ </description>
180+
181+ <enum name="stop_reason">
182+ <entry name="requested" value="0" summary="the client stopped it"/>
183+ <entry name="output_gone" value="1"/>
184+ <entry name="output_off" value="2"/>
185+ <entry name="ping_timeout" value="3"/>
186+ <!-- 4 is reserved: it stays unused so the values of the other reasons do not move -->
187+ <entry name="workspace_gone" value="5"/>
188+ <entry name="busy" value="6" summary="another session is live"/>
189+ <entry name="invalid_region" value="7"/>
190+ <entry name="unauthorized" value="8"/>
191+ <entry name="too_many_excluded" value="9" summary="attached layers and their popups exceed 128 surfaces"/>
192+ </enum>
193+
194+ <request name="destroy" type="destructor">
195+ <description summary="stop and destroy the session"/>
196+ </request>
197+
198+ <request name="ping">
199+ <description summary="keep the session alive">
200+ Must arrive at least every 5 seconds.
201+ </description>
202+ </request>
203+
204+ <event name="state">
205+ <description summary="what the compositor decided">
206+ Sent once the session exists and on every change. The token is 64
207+ lowercase hexadecimal characters (32 random bytes): a secret for the
208+ owner only, never logged, valid until stopped. The rectangle is the
209+ clipped target in output-local logical pixels and the workspace is
210+ the real one (0 for an output target). Active non-zero means captures of this connection are
211+ served clean. Active is 1 while the session lives: a session that is
212+ lost is stopped, never paused. The revision (64 bits, from 1) grows
213+ whenever the session's configuration changes: layers, popups, target.
214+ </description>
215+ <arg name="token" type="string"/>
216+ <arg name="active" type="uint"/>
217+ <arg name="x" type="int"/>
218+ <arg name="y" type="int"/>
219+ <arg name="width" type="int"/>
220+ <arg name="height" type="int"/>
221+ <arg name="workspace_hi" type="uint"/>
222+ <arg name="workspace_lo" type="uint"/>
223+ <arg name="revision_hi" type="uint"/>
224+ <arg name="revision_lo" type="uint"/>
225+ </event>
226+
227+ <event name="stopped">
228+ <description summary="the session ended">
229+ Final. The token is void. The compositor closes the attached layer
230+ surfaces (zwlr_layer_surface_v1.closed) and ends their attachments
231+ with detached. Destroy the object.
232+ </description>
233+ <arg name="reason" type="uint" enum="stop_reason"/>
234+ </event>
235+ </interface>
236+</protocol>
M internal/adapters/wayland/frames.go +15 -1
@@ -118,18 +118,32 @@ func toplevelRoot(surf *surface) *surface {
118118 return surf
119119 }
120120
121+// invisible reports that no display shows the toplevel tree: the physical
122+// truth, used for scanout and presentation feedback. A tree rendered only
123+// for a capture session is invisible.
121124 func (s *Server) invisible(surf *surface) bool {
122125 root := toplevelRoot(surf)
123126 return root.xdg != nil && root.xdg.window != nil && root.xdg.window.hasLast && !root.xdg.window.last.Visible
124127 }
125128
129+// suspended reports that nothing draws the toplevel tree, not even a capture
130+// session: it is throttled and marked suspended. A captured tree keeps the
131+// frame callbacks of its output so the capture keeps moving.
132+func (s *Server) suspended(surf *surface) bool {
133+ root := toplevelRoot(surf)
134+ if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast {
135+ return false
136+ }
137+ return !root.xdg.window.last.Visible && !root.xdg.window.last.Captured
138+}
139+
126140 // frameOutput is the pacing class for a surface; "" is the 60 Hz
127141 // outputless class, not the invisible toplevel class.
128142 func (s *Server) frameOutput(surf *surface) string {
129143 surf = toplevelRoot(surf)
130144 switch {
131145 case surf.xdg != nil && surf.xdg.window != nil:
132- if s.invisible(surf) {
146+ if s.suspended(surf) {
133147 return suspendedFrameQueue
134148 }
135149 return surf.xdg.window.last.Output
M internal/adapters/wayland/frames_test.go +22 -0
@@ -122,3 +122,25 @@ func TestInvisibleFramePacingAndMigration(t *testing.T) {
122122 t.Fatalf("resume: fire %v queues %+v", fire, s.awaiting)
123123 }
124124 }
125+
126+// A window only a capture session draws is physically invisible (no scanout,
127+// no presentation feedback) but is not throttled: its callbacks stay on its
128+// output's queue and move when Captured changes.
129+func TestCapturedWindowKeepsOutputFramePacing(t *testing.T) {
130+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
131+ s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1)}
132+ w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
133+ root := &surface{xdg: &xdgSurface{window: w}}
134+ a := new(wayland.Callback)
135+ s.queueFrames(root, []*wayland.Callback{a})
136+ w.last = ports.ConfigureWindow{Output: "DP-2", Captured: true}
137+ s.relocateCallbacks(root)
138+ if !s.invisible(root) || s.suspended(root) || s.frameOutput(root) != "DP-2" || len(s.awaiting["DP-2"]) != 1 || len(s.awaiting[suspendedFrameQueue]) != 0 {
139+ t.Fatalf("captured: invisible %v suspended %v queues %+v", s.invisible(root), s.suspended(root), s.awaiting)
140+ }
141+ w.last = ports.ConfigureWindow{Output: "DP-2"}
142+ s.relocateCallbacks(root)
143+ if !s.suspended(root) || len(s.awaiting[suspendedFrameQueue]) != 1 || len(s.awaiting["DP-2"]) != 0 {
144+ t.Fatalf("released: queues %+v", s.awaiting)
145+ }
146+}
M internal/adapters/wayland/globals.go +1 -0
@@ -30,6 +30,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
3030 func() error { return registerInhibit(d, s) },
3131 func() error { return registerIdle(d, s) },
3232 func() error { return registerCapture(d, s) },
33+ func() error { return registerCaptureSession(d, s) },
3334 func() error { return registerActivation(d, s) },
3435 func() error { return registerVirtualKeyboard(d, s) },
3536 func() error { return registerTextInput(d, s) },
M internal/adapters/wayland/layer.go +29 -0
@@ -31,6 +31,9 @@ type layerSurface struct {
3131 // creation. The surface is closed when it is unplugged.
3232 output *output
3333 closed bool // output gone: commits are ignored until destroy
34+ // capture is the private capture session this surface is attached to.
35+ capture *privateSession
36+ attachment *layerAttachment
3437 }
3538
3639 func registerLayer(d *server.Display, s *Server) error {
@@ -71,9 +74,12 @@ func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32
7174 h.server.layers[l.id] = l
7275 if gone {
7376 l.close()
77+ } else if out != nil {
78+ state.sendTreeScale()
7479 }
7580 resource.OnDestroy = func() {
7681 l.unmap()
82+ l.captureDetach()
7783 delete(h.server.layers, l.id)
7884 state.layer = nil
7985 state.role = nil
@@ -104,6 +110,10 @@ func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfac
104110 r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity), "invalid keyboard interactivity")
105111 return
106112 }
113+ if l.capture != nil {
114+ // A capture HUD never takes the keyboard, whatever it asks for.
115+ k = 0
116+ }
107117 l.pending.keyboard = k
108118 }
109119
@@ -175,6 +185,9 @@ func (l *layerSurface) unmap() {
175185 // close tells the client its output is gone; the surface stays unmapped
176186 // until the client destroys it.
177187 func (l *layerSurface) close() {
188+ if l.closed {
189+ return
190+ }
178191 l.unmap()
179192 l.output = nil
180193 l.closed = true
@@ -190,6 +203,10 @@ func (s *Server) layerChanged() {
190203 continue
191204 }
192205 v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace, Keyboard: l.current.keyboard}
206+ if l.capture != nil {
207+ // A capture HUD never takes the keyboard.
208+ v.Keyboard = 0
209+ }
193210 if l.output != nil {
194211 v.Output = l.output.name()
195212 }
@@ -233,12 +250,24 @@ func (l *layerSurface) commit(buffer bool) {
233250 func (l *layerSurface) commitState(p layerState, buffer bool) {
234251 if l.surface.destroyed {
235252 l.unmap()
253+ l.captureDetach()
236254 delete(l.shell.server.layers, l.id)
237255 return
238256 }
239257 if l.closed {
240258 return
241259 }
260+ if l.capture != nil {
261+ p.keyboard = 0
262+ if p.layer != ports.LayerTop && p.layer != ports.LayerOverlay {
263+ // Only top and overlay layers may be attached: closing unmaps the
264+ // surface (its LayerChanged is queued first), then it is detached,
265+ // so core keeps it excluded until it is gone.
266+ l.close()
267+ l.captureDetach()
268+ return
269+ }
270+ }
242271 if (p.width == 0 && p.anchor&(ports.AnchorLeft|ports.AnchorRight) != ports.AnchorLeft|ports.AnchorRight) || (p.height == 0 && p.anchor&(ports.AnchorTop|ports.AnchorBottom) != ports.AnchorTop|ports.AnchorBottom) {
243272 l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors")
244273 return
A internal/adapters/wayland/layer_scale_test.go +127 -0
@@ -0,0 +1,127 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/fractionalscale"
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
10+ "github.com/bnema/wlturbo"
11+)
12+
13+// layerScaleOrderProxy is a real protocol client recording fractional-scale
14+// and layer events in their wire order across the two protocol objects.
15+type layerScaleOrderProxy struct {
16+ wlturbo.BaseProxy
17+ layer bool
18+ events chan [3]uint32
19+}
20+
21+func (p *layerScaleOrderProxy) Dispatch(e *wlturbo.Event) {
22+ if !p.layer {
23+ p.events <- [3]uint32{0, e.Uint32()}
24+ return
25+ }
26+ switch e.Opcode {
27+ case uint16(wlrlayershell.ZwlrLayerSurfaceV1EventConfigure):
28+ _ = e.Uint32() // serial
29+ p.events <- [3]uint32{1, e.Uint32(), e.Uint32()}
30+ case uint16(wlrlayershell.ZwlrLayerSurfaceV1EventClosed):
31+ p.events <- [3]uint32{2}
32+ }
33+}
34+
35+func TestLayerTargetScaleBeforeConfigure(t *testing.T) {
36+ for _, target := range []string{"fractional", "focused", "inferred", "removed"} {
37+ t.Run(target, func(t *testing.T) {
38+ s, _, commands, dir := lifecycleServer(t)
39+ c := protocolClient(t, s, dir)
40+ fractional := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 900, Height: 600}, X: 1920, Width: 750, Height: 500, Scale: 1.2}
41+ commands <- ports.SetOutputs{Outputs: ports.Layout{testOutputs[0], fractional}, Focused: "HEADLESS-1"}
42+ waitOutputs(t, c, 2)
43+ var first, last uint32
44+ for n, g := range c.Registry().GetGlobals() {
45+ if g.Interface == "wl_output" {
46+ if first == 0 || n < first {
47+ first = n
48+ }
49+ last = max(last, n)
50+ }
51+ }
52+ var out uint32
53+ if target != "inferred" {
54+ name := last
55+ if target == "focused" {
56+ name = first
57+ }
58+ var err error
59+ out, err = c.Registry().BindID(name, "wl_output", 4)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ registerProtocol(t, c, out)
64+ }
65+ if target == "removed" {
66+ commands <- ports.SetOutputs{Outputs: testOutputs, Focused: "HEADLESS-1"}
67+ waitOutputs(t, c, 1)
68+ }
69+ comp := bindVersion(t, c, "wl_compositor", 6)
70+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
71+ fm := bindProtocol(t, c, "wp_fractional_scale_manager_v1")
72+ surf := c.AllocateID()
73+ integers := make(chan int32, 4)
74+ sp := &surfaceScaleProxy{scales: integers}
75+ sp.SetID(surf)
76+ c.Context().Register(sp)
77+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
78+ frac := c.AllocateID()
79+ events := make(chan [3]uint32, 8)
80+ fp := &layerScaleOrderProxy{events: events}
81+ fp.SetID(frac)
82+ c.Context().Register(fp)
83+ requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf)
84+ if err := c.Roundtrip(); err != nil {
85+ t.Fatal(err)
86+ }
87+ if len(events) != 1 || <-events != [3]uint32{0, 120} {
88+ t.Fatal("surface must initially use focused output scale 120")
89+ }
90+ if len(integers) != 1 || <-integers != 1 {
91+ t.Fatal("initial integer scale must be 1")
92+ }
93+ layer := c.AllocateID()
94+ lp := &layerScaleOrderProxy{layer: true, events: events}
95+ lp.SetID(layer)
96+ c.Context().Register(lp)
97+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerOverlay), "scale-test")
98+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(15))
99+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(0))
100+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
101+ if err := c.Roundtrip(); err != nil {
102+ t.Fatal(err)
103+ }
104+ want := [][3]uint32{{1, 1920, 1080}}
105+ switch target {
106+ case "fractional":
107+ want = [][3]uint32{{0, 144}, {1, 750, 500}}
108+ if len(integers) != 1 || <-integers != 2 {
109+ t.Fatal("target integer scale must update to 2")
110+ }
111+ case "removed":
112+ want = [][3]uint32{{2}}
113+ }
114+ if len(events) != len(want) {
115+ t.Fatalf("events: %d, want %v", len(events), want)
116+ }
117+ for _, expected := range want {
118+ if got := <-events; got != expected {
119+ t.Fatalf("event %v, want %v", got, expected)
120+ }
121+ }
122+ if len(integers) != 0 {
123+ t.Fatal("unexpected integer scale notification")
124+ }
125+ })
126+ }
127+}
M internal/adapters/wayland/outputs.go +1 -0
@@ -186,6 +186,7 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
186186 session.res.SendStopped()
187187 }
188188 }
189+ s.stopCaptureOnOutput(o.name())
189190 s.log.Info().Str("output", o.name()).Msg("output removed")
190191 for _, l := range s.layers {
191192 if l.output == o {
M internal/adapters/wayland/presentation.go +3 -1
@@ -94,7 +94,9 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb
9494 return
9595 }
9696 output := srv.frameOutput(s)
97- if win == 0 || output == "" || output == suspendedFrameQueue {
97+ // A tree only a capture session draws gets no presentation feedback: no
98+ // display presents it.
99+ if win == 0 || output == "" || output == suspendedFrameQueue || srv.invisible(s) {
98100 for _, fb := range pending {
99101 discard(fb)
100102 }
M internal/adapters/wayland/release.go +11 -4
@@ -15,7 +15,8 @@ import (
1515 // that window and neither shows nor queues the buffer. Outputs keep only
1616 // the latest content of a window, so they never draw an older buffer
1717 // again. heldTimeout guards against an output that stopped reporting
18-// (switched away, stalled): a buffer it does not scan out is released then.
18+// (switched away, stalled): a buffer it does not scan out or read on a
19+// separate child device is released then.
1920
2021 // heldTimeout releases held buffers of an output that stopped reporting.
2122 const heldTimeout = 100 * time.Millisecond
@@ -90,7 +91,8 @@ func (s *Server) releaseBufferSync(surf *surface, sync syncHold) {
9091 // releaseHeld records the output reports and releases the buffers no
9192 // output reads. Every output reads every window's content, so a buffer
9293 // waits until no live output shows or queues it and all of them have seen
93-// a later content of its window (or heldTimeout passed). It reports
94+// a later content of its window (or heldTimeout passed, without child reads).
95+// It reports
9496 // whether buffers are still held.
9597 func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) bool {
9698 for _, r := range reports {
@@ -138,14 +140,19 @@ func (s *Server) bufferReferenced(b *wayland.Buffer) bool {
138140 }
139141
140142 // keepHeld reports whether an output may still read a held buffer: one
141-// scans it out or queued it, or one has not got a later content of its
142-// window yet and heldTimeout has not passed.
143+// scans it out or queued it, a child renderer still reads its content, or
144+// one has not got a later content of its window yet and heldTimeout has
145+// not passed. Child reads are exempt from the timeout until the render
146+// finishes or its device shuts down.
143147 func keepHeld(h heldBuffer, reports []ports.OutputPresented, now time.Time) bool {
144148 seen := true
145149 for _, r := range reports {
146150 if h.id != 0 && (r.Shown == h.id || r.Queued == h.id) {
147151 return true
148152 }
153+ if seq, ok := r.ChildReads[h.window]; ok && seq <= h.after {
154+ return true
155+ }
149156 if r.Seen[h.window] <= h.after {
150157 seen = false
151158 }
M internal/adapters/wayland/release_test.go +82 -0
@@ -1,12 +1,20 @@
11 package wayland
22
33 import (
4+ "context"
5+ "errors"
6+ "image"
7+ "os"
48 "testing"
59 "time"
610
11+ "github.com/bnema/neferwl/internal/adapters/capture"
12+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
713 "github.com/bnema/neferwl/internal/ports"
814 "github.com/bnema/purego-libwayland/protocol/fifo"
915 "github.com/bnema/purego-libwayland/protocol/wayland"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
1018 )
1119
1220 // A cached child buffer is not released until its role is destroyed;
@@ -84,6 +92,80 @@ func TestKeepHeld(t *testing.T) {
8492 }
8593 }
8694
95+// Child reads on a separate device outlive the stale display report timeout.
96+// The same predicate protects wl_buffer.release and explicit release points.
97+func TestKeepHeldChildReadsBeyondTimeout(t *testing.T) {
98+ now := time.Unix(100, 0)
99+ report := ports.OutputPresented{
100+ Seen: map[ports.WindowID]uint64{1: 20, 2: 20},
101+ ChildReads: map[ports.WindowID]uint64{1: 5},
102+ }
103+ for _, id := range []uint64{0, 7} { // SHM / explicit points only, and dmabuf
104+ h := heldBuffer{window: 1, id: id, after: 5, at: now}
105+ if !keepHeld(h, []ports.OutputPresented{report}, now.Add(time.Second)) {
106+ t.Fatal("unfinished child read released after 100ms")
107+ }
108+ h.window = 2
109+ if keepHeld(h, []ports.OutputPresented{report}, now.Add(time.Second)) {
110+ t.Fatal("unrelated display window held by child")
111+ }
112+ h.window, h.after = 1, 4
113+ if keepHeld(h, []ports.OutputPresented{report}, now.Add(time.Second)) {
114+ t.Fatal("buffer older than child's content held")
115+ }
116+ h.after = 5
117+ finished := report
118+ finished.ChildReads = nil // render completed, or device closed
119+ if keepHeld(h, []ports.OutputPresented{finished}, now.Add(time.Second)) {
120+ t.Fatal("finished child read still holds buffer")
121+ }
122+ }
123+ allocs := testing.AllocsPerRun(100, func() {
124+ keepHeld(heldBuffer{window: 1, after: 5, at: now}, []ports.OutputPresented{report}, now.Add(time.Second))
125+ })
126+ if allocs != 0 {
127+ t.Fatalf("child release guard: %.1f allocs, want 0", allocs)
128+ }
129+ t.Logf("child release guard: %.1f allocs", allocs)
130+}
131+
132+// An actual unfinished child fence protects releases after the stale timeout,
133+// even when the display's Seen is unchanged (so no cap was necessary).
134+func TestKeepHeldUntilChildFenceCompletes(t *testing.T) {
135+ read, write, err := os.Pipe()
136+ require.NoError(t, err)
137+ defer write.Close()
138+ child := portsmocks.NewMockRenderer(t)
139+ child.EXPECT().Render(mock.Anything, mock.Anything).Return(read, nil).Once()
140+ // A failed readback does not finish the render's client-buffer reads.
141+ child.EXPECT().BeginCapture().Return(nil, errors.New("readback unavailable")).Once()
142+ child.EXPECT().Close().Return().Once()
143+ p := capture.NewPipeline(context.Background(), make(chan ports.CaptureDone, 1))
144+ defer p.Close(nil)
145+ p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
146+ s := ports.Scene{
147+ Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 2, H: 2}},
148+ CaptureScene: &ports.Scene{OutputWidth: 2, OutputHeight: 2, Scale: 1, Windows: []ports.SceneWindow{{ID: 1}}},
149+ }
150+ p.SubmitHidden(s, map[ports.WindowID]ports.SurfaceContent{1: {Seq: 5}}, []ports.CaptureRequest{{
151+ ID: 1, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2),
152+ }})
153+ now := time.Unix(100, 0)
154+ h := heldBuffer{window: 1, after: 5, at: now}
155+ seen := map[ports.WindowID]uint64{1: 5}
156+ got, reads, pending := p.CapHiddenSeen(seen)
157+ require.True(t, pending)
158+ require.Equal(t, seen, got)
159+ reports := []ports.OutputPresented{{Seen: got, ChildReads: reads}}
160+ require.True(t, keepHeld(h, reports, now.Add(time.Second)), "unsignalled fence outlives 100ms timeout")
161+ _, err = write.Write([]byte{1})
162+ require.NoError(t, err)
163+ got, reads, pending = p.CapHiddenSeen(seen)
164+ require.False(t, pending)
165+ reports[0] = ports.OutputPresented{Seen: got, ChildReads: reads}
166+ require.False(t, keepHeld(h, reports, now.Add(time.Second)), "signalled fence lifts child hold")
167+}
168+
87169 // A buffer replaced just after its window becomes invisible is still held:
88170 // a scanout plane may read it until the flip that hides the window.
89171 func TestInvisibleWindowBufferHeldUntilReport(t *testing.T) {
M internal/adapters/wayland/screencopy.go +6 -1
@@ -165,6 +165,11 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
165165 s.log.Debug().Str("output", o.name()).Int("inflight", len(s.captureInflight)).Msg("capture refused: too many captures in flight")
166166 return 0, false
167167 }
168+ clean, session, revision, allowed := s.captureTag(life, o)
169+ if !allowed {
170+ s.log.Debug().Str("output", o.name()).Msg("capture refused: private session not active")
171+ return 0, false
172+ }
168173 buf := s.buffers[b.Resource].(*buffer)
169174 fd, err := unix.Dup(int(buf.pool.file.Fd()))
170175 if err != nil {
@@ -185,7 +190,7 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
185190 reply(done)
186191 }
187192 }
188- req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: cursor, Dst: ports.SHMBuffer{File: os.NewFile(uintptr(fd), "capture"), Offset: buf.offset}, Width: buf.width, Height: buf.height, Stride: buf.stride, Format: buf.format}
193+ req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: cursor, Dst: ports.SHMBuffer{File: os.NewFile(uintptr(fd), "capture"), Offset: buf.offset}, Width: buf.width, Height: buf.height, Stride: buf.stride, Format: buf.format, Clean: clean, Session: session, CaptureRevision: revision}
189194 select {
190195 case s.channels.Captures <- req:
191196 s.log.Debug().Uint64("id", id).Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Bool("cursor", cursor).Msg("capture requested")
M internal/adapters/wayland/server.go +14 -8
@@ -81,13 +81,15 @@ type Server struct {
8181 captureInflight map[uint64]struct{}
8282 captureSources map[*server.Resource]*output
8383 captureSessions map[*captureSession]struct{}
84- display *server.Display
85- env procEnv
86- slotsPending bool // core waits for a slot window
87- name string
88- cleanup func()
89- log zerowrap.Logger
90- channels Channels
84+ // private is the private capture session state (capture_session.go).
85+ private privateCapture
86+ display *server.Display
87+ env procEnv
88+ slotsPending bool // core waits for a slot window
89+ name string
90+ cleanup func()
91+ log zerowrap.Logger
92+ channels Channels
9193 // awaiting holds frame callbacks by output name, due at its next
9294 // frame (frameDue, or its page flip).
9395 awaiting map[string][]*wayland.Callback
@@ -625,7 +627,9 @@ func (s *Server) apply(cmd ports.ClientCommand) {
625627 return
626628 }
627629 s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure")
628- visibilityChanged := w.hasLast && w.last.Visible != c.Visible
630+ // Callbacks move when the tree stops or starts being throttled: on
631+ // Visible or on Captured.
632+ visibilityChanged := w.hasLast && (w.last.Visible != c.Visible || w.last.Captured != c.Captured)
629633 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible
630634 w.last, w.hasLast = c, true
631635 if visibilityChanged {
@@ -651,6 +655,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
651655 off[name] = true
652656 }
653657 s.setOutputsOff(off)
658+ case ports.CaptureSessionState:
659+ s.captureState(c)
654660 case ports.UserActivity:
655661 s.userActivity()
656662 case ports.SlotsPending:
M internal/adapters/wayland/workspaces.go +1 -0
@@ -131,6 +131,7 @@ func (s *Server) updateWorkspaces(snapshot ports.Workspaces) {
131131 for _, m := range s.workspaceManagers {
132132 m.update(snapshot)
133133 }
134+ s.updateCaptureWorkspaces(snapshot)
134135 }
135136 func workspaceState(w ports.WorkspaceInfo) uint32 {
136137 var state uint32
M internal/adapters/wayland/xdg.go +1 -1
@@ -167,7 +167,7 @@ func toplevelStates(c ports.ConfigureWindow, version int32) []byte {
167167 if c.Activated {
168168 add(xdgshell.ToplevelStateActivated)
169169 }
170- if !c.Visible && version >= 6 {
170+ if !c.Visible && !c.Captured && version >= 6 {
171171 add(xdgshell.ToplevelStateSuspended)
172172 }
173173 if c.Fullscreen || c.Floating {
M internal/adapters/wayland/xdg_states_test.go +1 -0
@@ -23,6 +23,7 @@ func TestToplevelStates(t *testing.T) {
2323 {"fullscreen", ports.ConfigureWindow{Fullscreen: true, Visible: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen}},
2424 {"hidden tiled v6", ports.ConfigureWindow{}, 6, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateSuspended, xdgshell.ToplevelStateMaximized}, tiled...)},
2525 {"hidden fullscreen v6", ports.ConfigureWindow{Fullscreen: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen, xdgshell.ToplevelStateSuspended}},
26+ {"captured hidden tiled v6", ports.ConfigureWindow{Captured: true}, 6, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateMaximized}, tiled...)},
2627 {"hidden floating v5", ports.ConfigureWindow{Floating: true}, 5, nil},
2728 }
2829 for _, tt := range tests {
M internal/app/drm.go +1 -0
@@ -330,6 +330,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
330330 case <-ctx.Done():
331331 }
332332 }()
333+ o.NewCaptureRenderer = newRenderer
333334 progress.started(name, source)
334335 if cur := o.Cursor(); cur != nil {
335336 curs.set(name, cur)
M internal/app/headless.go +1 -1
@@ -31,7 +31,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
3131 }
3232 cur := &headless.Cursor{}
3333 curs.set(name, cur)
34- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}
34+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}
3535 set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error {
3636 return headless.Run(octx, opts, sc, cc, cu, cap)
3737 })
A internal/core/capture_session.go +301 -0
@@ -0,0 +1,301 @@
1+package core
2+
3+import (
4+ "context"
5+ "reflect"
6+ "slices"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// captureSession is the one live private capture session (ports/capture_session.go).
13+// Core owns its state; wayland only relays requests and decisions.
14+type captureSession struct {
15+ begin ports.CaptureSessionBegin
16+ layers []WindowID // attached layer surfaces, at most ports.MaxCaptureSessionLayers
17+ // retained are detached layer surfaces still listed by a screen: they
18+ // stay excluded until a LayerChanged stops listing them, so a HUD that
19+ // is destroyed is never captured during its last frame.
20+ retained []WindowID
21+ expired bool // no ping within ports.CaptureSessionTimeout
22+ rev uint64 // revision fence, bumped when the decision changes
23+ last captureView
24+ hasLast bool
25+}
26+
27+// captureView is what one evaluation of the session decided.
28+type captureView struct {
29+ state ports.CaptureSessionState
30+ output string
31+ scene *ports.SceneCapture // nil unless Active
32+ // hidden is set when the targeted workspace is not drawn as itself on
33+ // screen (another one is, or the overview previews it): it is rendered
34+ // for capture only (Scene.CaptureScene) and the physical scene carries no
35+ // border for it.
36+ hidden bool
37+}
38+
39+// captureBegin registers a session, or refuses it as busy: the live session
40+// is not disturbed.
41+func (c *Core) captureBegin(ctx context.Context, v ports.CaptureSessionBegin) error {
42+ if c.capture != nil {
43+ return c.command(ctx, ports.CaptureSessionState{ID: v.ID, Output: v.Output, Workspace: v.Workspace, Reason: ports.CaptureReasonBusy})
44+ }
45+ c.capture = &captureSession{begin: v}
46+ c.armCapture()
47+ return nil
48+}
49+
50+// captureLayer attaches or detaches a layer surface of the live session.
51+func (c *Core) captureLayer(v ports.CaptureSessionLayer) {
52+ s := c.capture
53+ if s == nil || s.begin.ID != v.ID {
54+ return
55+ }
56+ i := slices.Index(s.layers, v.Layer)
57+ switch {
58+ case v.Attached && i < 0 && len(s.layers) < ports.MaxCaptureSessionLayers:
59+ s.layers = append(s.layers, v.Layer)
60+ c.clampCaptureKeyboard()
61+ case !v.Attached && i >= 0:
62+ s.layers = slices.Delete(s.layers, i, i+1)
63+ if c.layerListed(v.Layer) {
64+ s.retained = append(s.retained, v.Layer)
65+ }
66+ }
67+}
68+
69+// layerListed reports whether a screen still lists the layer surface.
70+func (c *Core) layerListed(id WindowID) bool {
71+ for _, sc := range c.screens {
72+ for _, l := range sc.layers {
73+ if l.ID == id {
74+ return true
75+ }
76+ }
77+ }
78+ return false
79+}
80+
81+// pruneRetained forgets the detached layers no screen lists any more.
82+func (c *Core) pruneRetained() {
83+ if c.capture != nil {
84+ c.capture.retained = slices.DeleteFunc(c.capture.retained, func(id WindowID) bool { return !c.layerListed(id) })
85+ }
86+}
87+
88+// clampCaptureKeyboard gives every attached layer surface no keyboard
89+// interactivity at all, whatever it asks for (exclusive or on demand): a
90+// capture HUD never takes or steals the keyboard. Run whenever the layer
91+// list or the attached set changes.
92+func (c *Core) clampCaptureKeyboard() {
93+ if c.capture == nil {
94+ return
95+ }
96+ for _, sc := range c.screens {
97+ for i := range sc.layers {
98+ if sc.layers[i].Keyboard != 0 && (slices.Contains(c.capture.layers, sc.layers[i].ID) || slices.Contains(c.capture.retained, sc.layers[i].ID)) {
99+ sc.layers[i].Keyboard = 0
100+ }
101+ }
102+ }
103+}
104+
105+// captureEnd forgets the session on an explicit stop or its owner's disconnect.
106+func (c *Core) captureEnd(id uint64) {
107+ if c.capture != nil && c.capture.begin.ID == id {
108+ c.dropCapture()
109+ }
110+}
111+
112+// capturePing restarts the keep-alive timer.
113+func (c *Core) capturePing(id uint64) {
114+ if c.capture != nil && c.capture.begin.ID == id {
115+ c.armCapture()
116+ }
117+}
118+
119+func (c *Core) dropCapture() {
120+ c.capture = nil
121+ c.stopCapture()
122+ c.configures.cw.reset()
123+ for _, sc := range c.screens {
124+ sc.capture = nil
125+ }
126+}
127+
128+// armCapture (re)starts the keep-alive timer; it exists only while a session does.
129+func (c *Core) armCapture() {
130+ c.stopCapture()
131+ if c.ch.Clock != nil {
132+ t := c.ch.Clock.NewTimer(ports.CaptureSessionTimeout)
133+ c.captureC, c.captureStop = t.C(), t.Stop
134+ return
135+ }
136+ t := time.NewTimer(ports.CaptureSessionTimeout)
137+ c.captureC, c.captureStop = t.C, t.Stop
138+}
139+
140+func (c *Core) stopCapture() {
141+ if c.captureStop != nil {
142+ c.captureStop()
143+ }
144+ c.captureC, c.captureStop = nil, nil
145+}
146+
147+// captureExpired marks the session for the next publish to end it.
148+func (c *Core) captureExpired() {
149+ c.captureC, c.captureStop = nil, nil
150+ if c.capture != nil {
151+ c.capture.expired = true
152+ }
153+}
154+
155+// captureEvaluate decides the state of the session against the current
156+// screens, sends it when it changed and returns the view; nil without a
157+// session. A terminal state is sent once and the session is forgotten.
158+func (c *Core) captureEvaluate(ctx context.Context) (*captureView, error) {
159+ s := c.capture
160+ if s == nil {
161+ return nil, nil
162+ }
163+ c.pruneRetained()
164+ view := c.captureDecide(s)
165+ if view.state.Reason.Terminal() {
166+ c.dropCapture()
167+ return nil, c.command(ctx, view.state)
168+ }
169+ // Screens keep the attached layers visible over a fullscreen window
170+ // only while the session serves captures.
171+ for _, sc := range c.screens {
172+ sc.capture = nil
173+ }
174+ if view.state.Active {
175+ if i := c.screenIndex(view.output); i >= 0 {
176+ keep := make(map[WindowID]bool, len(s.layers))
177+ for _, id := range s.layers {
178+ keep[id] = true
179+ }
180+ c.screens[i].capture = keep
181+ }
182+ }
183+ // The revision grows whenever what a clean capture must do changes; the
184+ // scene of this publish carries it, as does the state sent now.
185+ view.state.Layers = slices.Clone(s.layers)
186+ changed := !s.hasLast || !sameCapture(s.last, view)
187+ if changed {
188+ s.rev++
189+ }
190+ view.state.Revision = s.rev
191+ if view.scene != nil {
192+ view.scene.Revision = s.rev
193+ }
194+ s.last, s.hasLast = view, true
195+ if changed {
196+ if err := c.command(ctx, view.state); err != nil {
197+ return nil, err
198+ }
199+ }
200+ return &view, nil
201+}
202+
203+// sameCapture compares two decisions, revision aside.
204+func sameCapture(a, b captureView) bool {
205+ a.state.Revision, b.state.Revision = 0, 0
206+ if a.scene != nil {
207+ s := *a.scene
208+ s.Revision = 0
209+ a.scene = &s
210+ }
211+ if b.scene != nil {
212+ s := *b.scene
213+ s.Revision = 0
214+ b.scene = &s
215+ }
216+ return reflect.DeepEqual(a, b)
217+}
218+
219+// captureDecide is the pure part of captureEvaluate.
220+func (c *Core) captureDecide(s *captureSession) captureView {
221+ b := s.begin
222+ st := ports.CaptureSessionState{ID: b.ID, Output: b.Output, Workspace: b.Workspace}
223+ v := captureView{state: st, output: b.Output}
224+ stop := func(r ports.CaptureReason) captureView {
225+ v.state.Reason = r
226+ return v
227+ }
228+ if s.expired {
229+ return stop(ports.CaptureReasonPingTimeout)
230+ }
231+ i := c.screenIndex(b.Output)
232+ if i < 0 || b.Output == "" {
233+ return stop(ports.CaptureReasonOutputGone)
234+ }
235+ sc := c.screens[i]
236+ if sc.off {
237+ return stop(ports.CaptureReasonOutputOff)
238+ }
239+ m := sc.mon
240+ // target is the area captures cover, before the session's own region.
241+ target := m.Output()
242+ var origin Rect // the viewport origin of a hidden workspace's CaptureScene
243+ if b.Workspace != 0 {
244+ owner, w := c.workspaceByID(b.Workspace)
245+ if w == nil || owner != sc {
246+ return stop(ports.CaptureReasonWorkspaceGone)
247+ }
248+ target, origin = w.Output, Rect{X: w.Output.X, Y: w.Output.Y}
249+ v.hidden = m.Current() != w || m.ov.open
250+ }
251+ r := b.Region
252+ if r.W > 0 && r.H > 0 {
253+ target = intersect(target, r)
254+ }
255+ if target.W <= 0 || target.H <= 0 {
256+ return stop(ports.CaptureReasonInvalidRegion)
257+ }
258+ v.state.Rect = target
259+ excluded := c.captureExcluded(s)
260+ if len(excluded) > ports.MaxCaptureExcluded {
261+ return stop(ports.CaptureReasonTooManyExcluded)
262+ }
263+ v.state.Active = true
264+ scene := &ports.SceneCapture{Session: b.ID, TargetRect: target, Excluded: excluded, Workspace: b.Workspace}
265+ if v.hidden {
266+ // Rendered from Scene.CaptureScene, whose origin is the workspace
267+ // viewport's: TargetRect follows it.
268+ scene.TargetRect.X -= origin.X
269+ scene.TargetRect.Y -= origin.Y
270+ } else if b.Record {
271+ scene.BorderWidth, scene.BorderColor = ports.CaptureBorderWidth, ports.CaptureBorderColor
272+ }
273+ v.scene = scene
274+ return v
275+}
276+
277+// captureExcluded lists the attached layers and every popup hanging from
278+// one of them, sorted. Layers not mapped yet are listed too: they are
279+// excluded from their first frame.
280+func (c *Core) captureExcluded(s *captureSession) []WindowID {
281+ out := append(slices.Clone(s.layers), s.retained...)
282+ for id := range c.popups {
283+ if root := c.popupRoot(id); slices.Contains(s.layers, root) || slices.Contains(s.retained, root) {
284+ out = append(out, id)
285+ }
286+ }
287+ slices.Sort(out)
288+ return out
289+}
290+
291+// workspaceByID finds a workspace, numbered or hidden, on any screen.
292+func (c *Core) workspaceByID(id uint64) (*screen, *Workspace) {
293+ for _, sc := range c.screens {
294+ for _, w := range sc.mon.all() {
295+ if w.ID == id {
296+ return sc, w
297+ }
298+ }
299+ }
300+ return nil, nil
301+}
A internal/core/capture_session_test.go +420 -0
@@ -0,0 +1,420 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/adapters/config"
9+ "github.com/bnema/neferwl/internal/core"
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+type captureRig struct {
15+ client chan ports.ClientEvent
16+ output chan ports.OutputEvent
17+ commands chan ports.ClientCommand
18+ scenes chan []ports.Scene
19+ workspaces chan ports.Workspaces
20+}
21+
22+// captureCore runs a core with one 100x80 output OUT-1; a nil clock gets a
23+// keep-alive timer that never fires.
24+func captureCore(t *testing.T, clock ports.Clock) *captureRig {
25+ t.Helper()
26+ cfg := config.Defaults()
27+ cfg.Border.Width = 0
28+ r := &captureRig{
29+ client: make(chan ports.ClientEvent, 16),
30+ output: make(chan ports.OutputEvent, 8),
31+ commands: make(chan ports.ClientCommand, 64),
32+ scenes: make(chan []ports.Scene, 1),
33+ workspaces: make(chan ports.Workspaces, 1),
34+ }
35+ if clock == nil {
36+ // The keep-alive timer never fires.
37+ timer := portsmocks.NewMockTimer(t)
38+ timer.EXPECT().Stop().Return(true).Maybe()
39+ timer.EXPECT().C().Return((<-chan time.Time)(nil)).Maybe()
40+ mc := portsmocks.NewMockClock(t)
41+ mc.EXPECT().Now().RunAndReturn(time.Now).Maybe()
42+ mc.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(timer).Maybe()
43+ clock = mc
44+ }
45+ c, err := core.New(cfg, core.Channels{Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes, Workspaces: r.workspaces, Clock: clock})
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ ctx, cancel := context.WithCancel(context.Background())
50+ t.Cleanup(cancel)
51+ go c.Run(ctx)
52+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
53+ scene(t, r.scenes)
54+ return r
55+}
56+
57+// state returns the next CaptureSessionState, skipping other commands.
58+func (r *captureRig) state(t *testing.T) ports.CaptureSessionState {
59+ t.Helper()
60+ for {
61+ if v, ok := receive(t, r.commands).(ports.CaptureSessionState); ok {
62+ return v
63+ }
64+ }
65+}
66+
67+// sceneWith returns the next scene whose capture presence equals want.
68+func (r *captureRig) sceneWith(t *testing.T, want bool) ports.Scene {
69+ t.Helper()
70+ for {
71+ if s := scene(t, r.scenes); (s.Capture != nil) == want {
72+ return s
73+ }
74+ }
75+}
76+
77+func (r *captureRig) layer(id ports.WindowID, layer ports.Layer, keyboard uint32) ports.LayerSurface {
78+ return ports.LayerSurface{ID: id, Layer: layer, Anchor: ports.AnchorTop | ports.AnchorLeft, Width: 10, Height: 10, Output: "OUT-1", Keyboard: keyboard}
79+}
80+
81+func TestCaptureSessionOutputTarget(t *testing.T) {
82+ r := captureCore(t, nil)
83+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1", Region: ports.Rect{X: -10, Y: 10, W: 50, H: 200}, Record: true}
84+ st := r.state(t)
85+ if !st.Active || st.Workspace != 0 || st.Rect != (ports.Rect{X: 0, Y: 10, W: 40, H: 70}) {
86+ t.Fatalf("state %+v", st)
87+ }
88+ s := r.sceneWith(t, true)
89+ want := &ports.SceneCapture{Session: 1, TargetRect: st.Rect, Excluded: []ports.WindowID{}, BorderWidth: ports.CaptureBorderWidth, BorderColor: ports.CaptureBorderColor}
90+ if s.Capture.Session != 1 || s.Capture.TargetRect != want.TargetRect || len(s.Capture.Excluded) != 0 || s.Capture.BorderWidth != 2 || s.Capture.BorderColor != "#ff3b30" || s.Capture.Workspace != 0 {
91+ t.Fatalf("capture %+v", s.Capture)
92+ }
93+ // Without Record there is no border, and a zero region is the whole output.
94+ r.client <- ports.CaptureSessionEnd{ID: 1}
95+ r.sceneWith(t, false)
96+ r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1"}
97+ if st := r.state(t); st.Rect != (ports.Rect{W: 100, H: 80}) {
98+ t.Fatalf("state %+v", st)
99+ }
100+ if s := r.sceneWith(t, true); s.Capture.BorderWidth != 0 || s.Capture.BorderColor != "" {
101+ t.Fatalf("border without record: %+v", s.Capture)
102+ }
103+}
104+
105+func TestCaptureSessionWorkspaceTarget(t *testing.T) {
106+ r := captureCore(t, nil)
107+ r.client <- ports.WindowMapped{ID: 1}
108+ scene(t, r.scenes)
109+ ws := receive(t, r.workspaces).Outputs[0].Workspaces
110+ if ws[0].Frame != (ports.Rect{W: 100, H: 80}) {
111+ t.Fatalf("frame %+v", ws[0])
112+ }
113+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1", Workspace: ws[0].ID}
114+ if st := r.state(t); !st.Active || st.Workspace != ws[0].ID || st.Rect != ws[0].Frame {
115+ t.Fatalf("state %+v", st)
116+ }
117+ if s := r.sceneWith(t, true); s.Capture.Workspace != ws[0].ID {
118+ t.Fatalf("capture %+v", s.Capture)
119+ }
120+ r.client <- ports.CaptureSessionEnd{ID: 1}
121+ r.sceneWith(t, false)
122+ // A workspace that does not exist ends the session.
123+ r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1", Workspace: 9999}
124+ if st := r.state(t); st.Active || st.Reason != ports.CaptureReasonWorkspaceGone || !st.Reason.Terminal() {
125+ t.Fatalf("state %+v", st)
126+ }
127+}
128+
129+func TestCaptureSessionTerminalReasons(t *testing.T) {
130+ r := captureCore(t, nil)
131+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "NOPE"}
132+ if st := r.state(t); st.Reason != ports.CaptureReasonOutputGone || st.Active {
133+ t.Fatalf("state %+v", st)
134+ }
135+ r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1", Region: ports.Rect{X: 500, Y: 500, W: 5, H: 5}}
136+ if st := r.state(t); st.Reason != ports.CaptureReasonInvalidRegion {
137+ t.Fatalf("state %+v", st)
138+ }
139+ // An unplugged output ends the session.
140+ r.client <- ports.CaptureSessionBegin{ID: 3, Output: "OUT-1"}
141+ if st := r.state(t); !st.Active {
142+ t.Fatalf("state %+v", st)
143+ }
144+ r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
145+ r.output <- ports.OutputRemoved{Name: "OUT-1"}
146+ if st := r.state(t); st.ID != 3 || st.Reason != ports.CaptureReasonOutputGone {
147+ t.Fatalf("state %+v", st)
148+ }
149+ r.sceneWith(t, false)
150+}
151+
152+func TestCaptureSessionBusy(t *testing.T) {
153+ r := captureCore(t, nil)
154+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
155+ if st := r.state(t); st.ID != 1 || !st.Active {
156+ t.Fatalf("state %+v", st)
157+ }
158+ r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1"}
159+ if st := r.state(t); st.ID != 2 || st.Reason != ports.CaptureReasonBusy || st.Active {
160+ t.Fatalf("state %+v", st)
161+ }
162+ // The first session is undisturbed and ends only on its own request.
163+ r.client <- ports.CaptureSessionEnd{ID: 2}
164+ r.client <- ports.CaptureSessionEnd{ID: 1}
165+ r.sceneWith(t, false)
166+ r.client <- ports.CaptureSessionBegin{ID: 3, Output: "OUT-1"}
167+ if st := r.state(t); st.ID != 3 || !st.Active {
168+ t.Fatalf("state %+v", st)
169+ }
170+}
171+
172+// Attached layers stay over a fullscreen window while the session is active,
173+// are excluded with their popups, and other layers keep hiding.
174+func TestCaptureSessionLayersOverFullscreen(t *testing.T) {
175+ r := captureCore(t, nil)
176+ r.client <- ports.WindowMapped{ID: 1}
177+ scene(t, r.scenes)
178+ r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
179+ scene(t, r.scenes)
180+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerTop, 0)}}
181+ if s := scene(t, r.scenes); len(s.Layers) != 0 {
182+ t.Fatalf("fullscreen should hide layers: %+v", s.Layers)
183+ }
184+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
185+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
186+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true} // duplicate
187+ r.client <- ports.PopupRequest{ID: 20, Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
188+ r.client <- ports.PopupMapped{ID: 20}
189+ var s ports.Scene
190+ for {
191+ s = r.sceneWith(t, true)
192+ if len(s.Capture.Excluded) == 2 {
193+ break
194+ }
195+ }
196+ if s.Capture.Excluded[0] != 10 || s.Capture.Excluded[1] != 20 {
197+ t.Fatalf("excluded %v", s.Capture.Excluded)
198+ }
199+ if len(s.Layers) != 1 || s.Layers[0].ID != 10 {
200+ t.Fatalf("only the attached layer shows over fullscreen: %+v", s.Layers)
201+ }
202+ r.client <- ports.CaptureSessionEnd{ID: 1}
203+ if s = r.sceneWith(t, false); len(s.Layers) != 0 {
204+ t.Fatalf("layers after the session: %+v", s.Layers)
205+ }
206+}
207+
208+func TestCaptureSessionLayerBounds(t *testing.T) {
209+ r := captureCore(t, nil)
210+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
211+ for id := ports.WindowID(1); id <= ports.MaxCaptureSessionLayers+3; id++ {
212+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: id, Attached: true}
213+ }
214+ r.client <- ports.CaptureSessionLayer{ID: 99, Layer: 50, Attached: true} // another session
215+ var s ports.Scene
216+ for {
217+ s = r.sceneWith(t, true)
218+ if len(s.Capture.Excluded) == ports.MaxCaptureSessionLayers {
219+ break
220+ }
221+ }
222+ for _, id := range s.Capture.Excluded {
223+ if id > ports.MaxCaptureSessionLayers {
224+ t.Fatalf("excluded %v", s.Capture.Excluded)
225+ }
226+ }
227+ // Detaching removes one.
228+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 1}
229+ for {
230+ if s = r.sceneWith(t, true); len(s.Capture.Excluded) == ports.MaxCaptureSessionLayers-1 {
231+ break
232+ }
233+ }
234+}
235+
236+// An attached layer asking for exclusive keyboard never gets it; on-demand stays.
237+func TestCaptureSessionKeyboardClamp(t *testing.T) {
238+ r := captureCore(t, nil)
239+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerOverlay, 0)}}
240+ scene(t, r.scenes)
241+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
242+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
243+ r.sceneWith(t, true)
244+ // The HUD commits exclusive afterwards; an unrelated layer may have it.
245+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 1), r.layer(11, ports.LayerOverlay, 1)}}
246+ for len(r.commands) > 0 {
247+ <-r.commands
248+ }
249+ scene(t, r.scenes)
250+ for {
251+ f, ok := receive(t, r.commands).(ports.FocusWindow)
252+ if ok {
253+ if f.ID != 11 {
254+ t.Fatalf("keyboard went to %d, want the unattached layer 11", f.ID)
255+ }
256+ return
257+ }
258+ }
259+}
260+
261+func TestCaptureSessionPingTimeout(t *testing.T) {
262+ fire := make(chan time.Time)
263+ timer := portsmocks.NewMockTimer(t)
264+ timer.EXPECT().Stop().Return(true).Maybe()
265+ timer.EXPECT().C().Return(fire)
266+ clock := portsmocks.NewMockClock(t)
267+ clock.EXPECT().Now().RunAndReturn(time.Now).Maybe()
268+ clock.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(timer).Once()
269+ r := captureCore(t, clock)
270+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
271+ if st := r.state(t); !st.Active {
272+ t.Fatalf("state %+v", st)
273+ }
274+ fire <- time.Now()
275+ if st := r.state(t); st.ID != 1 || st.Reason != ports.CaptureReasonPingTimeout || st.Active {
276+ t.Fatalf("state %+v", st)
277+ }
278+ r.sceneWith(t, false)
279+}
280+
281+// A ping restarts the timer; the session then times out from the last one.
282+func TestCaptureSessionPingRestartsTimer(t *testing.T) {
283+ first, second := make(chan time.Time), make(chan time.Time)
284+ t1, t2 := portsmocks.NewMockTimer(t), portsmocks.NewMockTimer(t)
285+ t1.EXPECT().Stop().Return(true).Once()
286+ t1.EXPECT().C().Return(first)
287+ t2.EXPECT().Stop().Return(true).Maybe()
288+ t2.EXPECT().C().Return(second)
289+ clock := portsmocks.NewMockClock(t)
290+ clock.EXPECT().Now().RunAndReturn(time.Now).Maybe()
291+ clock.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(t1).Once()
292+ clock.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(t2).Once()
293+ r := captureCore(t, clock)
294+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
295+ r.state(t)
296+ r.client <- ports.CaptureSessionPing{ID: 1}
297+ r.client <- ports.CaptureSessionPing{ID: 7} // unknown: no effect
298+ r.client <- ports.WindowMapped{ID: 1} // a round trip through the owner loop
299+ scene(t, r.scenes)
300+ second <- time.Now()
301+ if st := r.state(t); st.Reason != ports.CaptureReasonPingTimeout {
302+ t.Fatalf("state %+v", st)
303+ }
304+}
305+
306+// Every change a clean capture depends on bumps the revision, in the state
307+// and in the scene that carries it; an unchanged republish does not.
308+func TestCaptureSessionRevisionFence(t *testing.T) {
309+ r := captureCore(t, nil)
310+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
311+ st := r.state(t)
312+ if st.Revision != 1 || len(st.Layers) != 0 {
313+ t.Fatalf("state %+v", st)
314+ }
315+ if s := r.sceneWith(t, true); s.Capture.Revision != 1 {
316+ t.Fatalf("scene revision %d", s.Capture.Revision)
317+ }
318+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
319+ st = r.state(t)
320+ if st.Revision != 2 || len(st.Layers) != 1 || st.Layers[0] != 10 {
321+ t.Fatalf("state after attach %+v", st)
322+ }
323+ var s ports.Scene
324+ for {
325+ if s = r.sceneWith(t, true); s.Capture.Revision == 2 {
326+ break
327+ }
328+ }
329+ if len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 {
330+ t.Fatalf("scene %+v", s.Capture)
331+ }
332+ // An unrelated publish keeps the revision and sends no state.
333+ r.client <- ports.WindowMapped{ID: 1}
334+ if s = r.sceneWith(t, true); s.Capture.Revision != 2 {
335+ t.Fatalf("revision moved without a change: %d", s.Capture.Revision)
336+ }
337+ for len(r.commands) > 0 {
338+ if v, ok := (<-r.commands).(ports.CaptureSessionState); ok {
339+ t.Fatalf("state without a change: %+v", v)
340+ }
341+ }
342+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10}
343+ if st = r.state(t); st.Revision != 3 || len(st.Layers) != 0 {
344+ t.Fatalf("state after detach %+v", st)
345+ }
346+}
347+
348+// A detached layer stays excluded until a LayerChanged stops listing it, so
349+// the last frame of a destroyed HUD never leaks into a capture.
350+func TestCaptureSessionDetachRetainsExclusion(t *testing.T) {
351+ r := captureCore(t, nil)
352+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
353+ scene(t, r.scenes)
354+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
355+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
356+ for {
357+ if s := r.sceneWith(t, true); len(s.Capture.Excluded) == 1 {
358+ break
359+ }
360+ }
361+ // Detach while the layer is still listed: still excluded.
362+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10}
363+ s := r.sceneWith(t, true)
364+ if len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 {
365+ t.Fatalf("detached layer no longer excluded while mapped: %+v", s.Capture)
366+ }
367+ // Once the layer unmaps, the exclusion goes.
368+ r.client <- ports.LayerChanged{Layers: nil}
369+ for {
370+ if s = r.sceneWith(t, true); len(s.Capture.Excluded) == 0 {
371+ break
372+ }
373+ }
374+}
375+
376+// An unmap queued before the detach (the wayland order) never leaves a
377+// window where the layer is listed but not excluded.
378+func TestCaptureSessionUnmapThenDetach(t *testing.T) {
379+ r := captureCore(t, nil)
380+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
381+ scene(t, r.scenes)
382+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
383+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
384+ for {
385+ if s := r.sceneWith(t, true); len(s.Capture.Excluded) == 1 {
386+ break
387+ }
388+ }
389+ r.client <- ports.LayerChanged{Layers: nil}
390+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10}
391+ for {
392+ s := r.sceneWith(t, true)
393+ if len(s.Layers) != 0 && len(s.Capture.Excluded) == 0 {
394+ t.Fatalf("layer shown but not excluded: %+v %+v", s.Layers, s.Capture)
395+ }
396+ if len(s.Layers) == 0 {
397+ return
398+ }
399+ }
400+}
401+
402+// An attached layer never gets keyboard interactivity, exclusive or on demand.
403+func TestCaptureSessionKeyboardNone(t *testing.T) {
404+ r := captureCore(t, nil)
405+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 2), r.layer(11, ports.LayerOverlay, 2)}}
406+ scene(t, r.scenes)
407+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"}
408+ r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true}
409+ r.sceneWith(t, true)
410+ for len(r.commands) > 0 {
411+ <-r.commands
412+ }
413+ r.client <- ports.WindowMapped{ID: 1}
414+ scene(t, r.scenes)
415+ for len(r.commands) > 0 {
416+ if f, ok := (<-r.commands).(ports.FocusWindow); ok && f.ID == 10 {
417+ t.Fatalf("keyboard went to the attached layer")
418+ }
419+ }
420+}
A internal/core/capture_workspace.go +188 -0
@@ -0,0 +1,188 @@
1+package core
2+
3+import (
4+ "slices"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// captureWorkspace is the workspace a capture session renders off screen
10+// while it is not the one on screen. Its windows keep a real layout in the
11+// workspace viewport: they are sized (configure Captured) and drawn into
12+// Scene.CaptureScene, but they are hidden on the physical layout, so they
13+// take no pointer input and no physical presentation feedback.
14+//
15+// Only the core goroutine touches it. The index map and its storage are
16+// reused by every publish (cleared, never reallocated once warm).
17+type captureWorkspace struct {
18+ sc *screen
19+ ws *Workspace // nil: no workspace is captured off screen
20+ frame Rect // ws.Output: the viewport, in monitor coordinates
21+ placements []Placement
22+ index map[WindowID]int
23+}
24+
25+// active reports whether a hidden workspace is captured.
26+func (cw *captureWorkspace) active() bool { return cw != nil && cw.ws != nil }
27+
28+// reset forgets the captured workspace, keeping the storage.
29+func (cw *captureWorkspace) reset() {
30+ cw.sc, cw.ws, cw.frame = nil, nil, Rect{}
31+ cw.placements = nil
32+ clear(cw.index)
33+}
34+
35+// load lays out ws in its own viewport. ws.Layout allocates the placements
36+// (the baseline of the existing layout); the index reuses its map.
37+func (cw *captureWorkspace) load(sc *screen, ws *Workspace) {
38+ cw.sc, cw.ws, cw.frame = sc, ws, ws.Output
39+ cw.placements = ws.Layout()
40+ if cw.index == nil {
41+ cw.index = make(map[WindowID]int, len(cw.placements))
42+ }
43+ clear(cw.index)
44+ for i := range cw.placements {
45+ cw.index[cw.placements[i].ID] = i
46+ }
47+}
48+
49+// placement is the real placement of a window of the captured workspace.
50+func (cw *captureWorkspace) placement(id WindowID) *Placement {
51+ if !cw.active() {
52+ return nil
53+ }
54+ if i, ok := cw.index[id]; ok {
55+ return &cw.placements[i]
56+ }
57+ return nil
58+}
59+
60+// captureTrack follows the session view of this publish: it lays out the
61+// captured workspace when it is not drawn as itself (another workspace is
62+// on screen, or the overview shows it as a preview) and forgets it
63+// otherwise. It runs
64+// right after captureEvaluate, before the popups and configures that use it.
65+func (c *Core) captureTrack(view *captureView) {
66+ // Tracking follows the non-terminal view, not its Active flag: while the
67+ // captured workspace slides in its own viewport its windows stay captured
68+ // (not suspended) and its popups stay. A terminal or absent session has no
69+ // view.
70+ if view == nil || !view.hidden || view.state.Workspace == 0 {
71+ c.configures.cw.reset()
72+ return
73+ }
74+ sc, ws := c.workspaceByID(view.state.Workspace)
75+ if ws == nil || sc == nil || sc.name() != view.output || (sc.mon.Current() == ws && !sc.mon.ov.open) {
76+ c.configures.cw.reset()
77+ return
78+ }
79+ c.configures.cw.load(sc, ws)
80+}
81+
82+// captureConfigure picks how a physical placement p on sc is configured:
83+// for a window of the captured workspace it returns the real placement and
84+// the target sized for it, else nil and t unchanged. Pass both to
85+// configures.nextWithCapture.
86+func (c *Core) captureConfigure(sc *screen, p Placement, t configureTarget) (*Placement, configureTarget) {
87+ cw := &c.configures.cw
88+ if !cw.active() || cw.sc != sc {
89+ return nil, t
90+ }
91+ cp := cw.placement(p.ID)
92+ if cp == nil {
93+ return nil, t
94+ }
95+ t.captureArea = cw.frame
96+ if !p.Hidden {
97+ // An overview preview keeps its real size and physical state.
98+ return cp, t
99+ }
100+ t.area = cw.frame
101+ t.focused = false
102+ t.client, t.imposed = Rect{}, false
103+ if !cp.Hidden {
104+ t.client, t.imposed = c.clientRect(*cp), cw.ws.imposedFloat(cp.ID)
105+ }
106+ return cp, t
107+}
108+
109+// captureRect is where a window of the captured workspace, or a popup
110+// hanging from one, is: its client area in monitor coordinates. Unlike
111+// windowRect it knows hidden workspaces; it never serves input. A stale
112+// captured workspace (its screen or workspace gone) has no geometry.
113+func (c *Core) captureRect(id WindowID) (*screen, Rect, bool) {
114+ cw := &c.configures.cw
115+ if !cw.active() || !slices.Contains(c.screens, cw.sc) || !cw.sc.mon.has(cw.ws) {
116+ return nil, Rect{}, false
117+ }
118+ if p := c.popups[id]; p != nil {
119+ sc, pr, ok := c.captureRect(p.parent)
120+ if !ok {
121+ return nil, Rect{}, false
122+ }
123+ if cp := cw.placement(c.popupRoot(id)); cp != nil && cp.Peek {
124+ return nil, Rect{}, false
125+ }
126+ return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true
127+ }
128+ cp := cw.placement(id)
129+ if cp == nil || cp.Hidden || !cp.Rect.Overlaps(cw.frame) {
130+ return nil, Rect{}, false
131+ }
132+ return cw.sc, c.clientRect(*cp), true
133+}
134+
135+// popupRect is windowRect that also knows the captured workspace; physical
136+// tells which one answered (a single physical lookup).
137+func (c *Core) popupRect(id WindowID) (sc *screen, r Rect, physical, ok bool) {
138+ if sc, r, ok := c.windowRect(id); ok {
139+ return sc, r, true, true
140+ }
141+ sc, r, ok = c.captureRect(id)
142+ return sc, r, false, ok
143+}
144+
145+// captureScene is the captured workspace drawn for capture only: a scene the
146+// size of its viewport, whose windows and lines are rebased on the viewport
147+// origin. It has no layers and no capture of its own. nil without a
148+// captured workspace.
149+func (c *Core) captureScene(seq uint64) *ports.Scene {
150+ cw := &c.configures.cw
151+ if !cw.active() {
152+ return nil
153+ }
154+ f := cw.frame
155+ rebase := func(r Rect) Rect { r.X, r.Y = r.X-f.X, r.Y-f.Y; return r }
156+ s := &ports.Scene{
157+ Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H},
158+ Scale: cw.sc.scale, Background: c.cfg.Background.Color,
159+ Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive},
160+ Windows: make([]ports.SceneWindow, 0, len(cw.placements)),
161+ Dim: floatDim(cw.placements, f, c.cfg.Floating.Dim),
162+ }
163+ // A hidden workspace lights nothing: its focus is not shown.
164+ for _, p := range cw.placements {
165+ w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview}
166+ if !p.Hidden {
167+ w.Rect = rebase(p.Rect)
168+ }
169+ if p.Peek {
170+ w.Dim = c.cfg.Stash.Dim
171+ }
172+ s.Windows = append(s.Windows, w)
173+ }
174+ s.Separators = separators(cw.placements, c.cfg.Border.Width, cw.ws.gap(), f, false)
175+ for i := range s.Separators {
176+ s.Separators[i].Rect = rebase(s.Separators[i].Rect)
177+ }
178+ for _, id := range c.popupOrder {
179+ p := c.popups[id]
180+ if p == nil || !p.mapped {
181+ continue
182+ }
183+ if _, r, ok := c.captureRect(id); ok {
184+ s.Windows = append(s.Windows, ports.SceneWindow{ID: id, Rect: rebase(r), Popup: true})
185+ }
186+ }
187+ return s
188+}
A internal/core/capture_workspace_e2e_test.go +86 -0
@@ -0,0 +1,86 @@
1+package core_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// configureOf returns the next ConfigureWindow of id, skipping other commands.
10+func (r *captureRig) configureOf(t *testing.T, id ports.WindowID) ports.ConfigureWindow {
11+ t.Helper()
12+ for {
13+ if v, ok := receive(t, r.commands).(ports.ConfigureWindow); ok && v.ID == id {
14+ return v
15+ }
16+ }
17+}
18+
19+// A session keeps capturing a workspace that leaves the screen: the state
20+// stays active, the scene carries a sibling CaptureScene, and the window is
21+// configured Captured (sized, physically invisible) until the session stops.
22+func TestCaptureSessionSurvivesWorkspaceSwitch(t *testing.T) {
23+ r := captureCore(t, nil)
24+ r.client <- ports.WindowMapped{ID: 1}
25+ scene(t, r.scenes)
26+ // Mapping the window adds a spare workspace: wait for the inventory that lists it.
27+ var ws []ports.WorkspaceInfo
28+ for len(ws) < 2 {
29+ ws = receive(t, r.workspaces).Outputs[0].Workspaces
30+ }
31+ r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1", Workspace: ws[0].ID}
32+ if st := r.state(t); !st.Active {
33+ t.Fatalf("state %+v", st)
34+ }
35+ on := r.sceneWith(t, true)
36+ if on.CaptureScene != nil {
37+ t.Fatalf("a workspace on screen needs no capture scene: %+v", on.CaptureScene)
38+ }
39+ // The window is shown and not Captured until its workspace leaves.
40+ for len(r.commands) > 0 {
41+ if v, ok := (<-r.commands).(ports.ConfigureWindow); ok && v.ID == 1 && (!v.Visible || v.Captured) {
42+ t.Fatalf("shown window %+v", v)
43+ }
44+ }
45+
46+ r.client <- ports.WorkspaceActivate{IDs: []uint64{ws[1].ID}}
47+ var off ports.Scene
48+ for {
49+ off = scene(t, r.scenes)
50+ if off.CaptureScene != nil {
51+ break
52+ }
53+ }
54+ if off.Capture.Workspace != ws[0].ID {
55+ t.Fatalf("capture %+v", off.Capture)
56+ }
57+ c := off.CaptureScene
58+ if c.Capture != nil || c.CaptureScene != nil || len(c.Layers) != 0 || c.Seq != off.Seq || c.OutputWidth != 100 || c.OutputHeight != 80 {
59+ t.Fatalf("capture scene %+v (root seq %d)", c, off.Seq)
60+ }
61+ if len(c.Windows) != 1 || c.Windows[0].ID != 1 || c.Windows[0].Hidden || c.Windows[0].Rect.W <= 0 {
62+ t.Fatalf("capture windows %+v", c.Windows)
63+ }
64+ for _, w := range off.Windows {
65+ if w.ID == 1 && !w.Hidden {
66+ t.Fatalf("captured window drawn on screen: %+v", w)
67+ }
68+ }
69+ hid := r.configureOf(t, 1)
70+ if hid.Visible || hid.Activated || !hid.Captured || hid.Width <= 0 {
71+ t.Fatalf("captured hidden window %+v", hid)
72+ }
73+
74+ // Stopping the session hides the window for real again.
75+ r.client <- ports.CaptureSessionEnd{ID: 1}
76+ var after ports.Scene
77+ for {
78+ if after = scene(t, r.scenes); after.Capture == nil {
79+ break
80+ }
81+ }
82+ end := r.configureOf(t, 1)
83+ if end.Captured || end.Visible || end.Width != hid.Width {
84+ t.Fatalf("released window %+v", end)
85+ }
86+}
A internal/core/capture_workspace_test.go +274 -0
@@ -0,0 +1,274 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// hiddenCapture is a 300x200 output whose first workspace (windows 1 and 2,
12+// a 100x100 viewport) is off screen, captured.
13+func hiddenCapture(t *testing.T) (*Core, *Workspace) {
14+ t.Helper()
15+ c, ws, _ := hiddenCaptureCommands(t)
16+ return c, ws
17+}
18+
19+// hiddenCaptureCommands is hiddenCapture with the channel core sends its commands on.
20+func hiddenCaptureCommands(t *testing.T) (*Core, *Workspace, chan ports.ClientCommand) {
21+ t.Helper()
22+ commands := make(chan ports.ClientCommand, 64)
23+ var cfg ports.Config
24+ cfg.Keyboard.CmdKey = "super"
25+ cfg.Layout.MaxColumns = 2
26+ cfg.Border.Width = 2
27+ cfg.Background.Color = "#102030"
28+ c, err := New(cfg, Channels{
29+ Scenes: make(chan []ports.Scene, 1), Commands: commands,
30+ })
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
35+ m := c.screens[0].mon
36+ ws := m.Current()
37+ ws.SetSize(100, 100)
38+ m.AddWindow(1)
39+ m.AddWindow(2)
40+ m.Focus(1)
41+ if m.Current() == ws {
42+ t.Fatal("workspace still on screen")
43+ }
44+ c.configures.cw.load(c.screens[0], ws)
45+ return c, ws, commands
46+}
47+
48+func TestCaptureConfigureSizesHiddenWindowWithoutShowingIt(t *testing.T) {
49+ c, _ := hiddenCapture(t)
50+ sc := c.screens[0]
51+ phys := Placement{ID: 1, Hidden: true}
52+ t0 := configureTarget{output: "A", area: sc.mon.Frame()}
53+ cp, tt := c.captureConfigure(sc, phys, t0)
54+ if cp == nil || cp.Hidden {
55+ t.Fatalf("no real placement: %+v", cp)
56+ }
57+ v, send := c.configures.nextWithCapture(phys, tt, cp)
58+ want := c.clientRect(*cp)
59+ if !send || v.Width != want.W || v.Height != want.H || want.W == 0 {
60+ t.Fatalf("configure %+v send %v want size %+v", v, send, want)
61+ }
62+ if v.Visible || v.Activated || !v.Captured || v.Output != "A" {
63+ t.Fatalf("captured hidden window must be sized, unfocused, physically invisible: %+v", v)
64+ }
65+ c.configures.mark(v)
66+ if _, send := c.configures.nextWithCapture(phys, tt, cp); send {
67+ t.Fatal("unchanged captured configure sent again")
68+ }
69+ // The session stops: the window is only hidden again, keeps its size and drops Captured.
70+ c.configures.cw.reset()
71+ cp, tt = c.captureConfigure(sc, phys, t0)
72+ if cp != nil {
73+ t.Fatal("placement kept after the session")
74+ }
75+ v2, send := c.configures.nextWithCapture(phys, tt, cp)
76+ if !send || v2.Captured || v2.Visible || v2.Width != v.Width || v2.Height != v.Height {
77+ t.Fatalf("released window %+v send %v after %+v", v2, send, v)
78+ }
79+}
80+
81+func TestCaptureConfigureLeavesOtherWindowsAlone(t *testing.T) {
82+ c, _ := hiddenCapture(t)
83+ sc := c.screens[0]
84+ phys := Placement{ID: 99, Hidden: true}
85+ t0 := configureTarget{output: "A", area: sc.mon.Frame()}
86+ if cp, tt := c.captureConfigure(sc, phys, t0); cp != nil || tt != t0 {
87+ t.Fatalf("window of another workspace captured: %+v", cp)
88+ }
89+ v, _ := c.configures.nextWithCapture(phys, t0, nil)
90+ if v.Captured || v.Visible || v.Width != 0 {
91+ t.Fatalf("plain hidden configure %+v", v)
92+ }
93+}
94+
95+func TestCaptureSceneRebasesOnViewport(t *testing.T) {
96+ c, ws := hiddenCapture(t)
97+ f := ws.Output
98+ if f != (Rect{X: 100, Y: 50, W: 100, H: 100}) {
99+ t.Fatalf("viewport %+v", f)
100+ }
101+ c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
102+ c.popupOrder = append(c.popupOrder, 10)
103+ s := c.captureScene(7)
104+ if s == nil || s.OutputWidth != 100 || s.OutputHeight != 100 || s.WorkspaceClip != (Rect{W: 100, H: 100}) || s.Seq != 7 || s.Output != "A" {
105+ t.Fatalf("scene %+v", s)
106+ }
107+ if s.Capture != nil || s.CaptureScene != nil || len(s.Layers) != 0 || s.Background != "#102030" || s.Border.Width != 2 {
108+ t.Fatalf("capture scene must be flat, layerless and use the normal config: %+v", s)
109+ }
110+ byID := map[WindowID]ports.SceneWindow{}
111+ for _, w := range s.Windows {
112+ byID[w.ID] = w
113+ }
114+ for _, p := range ws.Layout() {
115+ w := byID[p.ID]
116+ if want := (Rect{X: p.Rect.X - f.X, Y: p.Rect.Y - f.Y, W: p.Rect.W, H: p.Rect.H}); w.Rect != want || w.Focused {
117+ t.Fatalf("window %d rect %+v want %+v focused %v", p.ID, w.Rect, want, w.Focused)
118+ }
119+ if w.Rect.X < 0 || w.Rect.Y < 0 || w.Rect.X+w.Rect.W > 100 || w.Rect.Y+w.Rect.H > 100 {
120+ t.Fatalf("window %d outside its viewport: %+v", p.ID, w.Rect)
121+ }
122+ }
123+ client := c.clientRect(ws.Layout()[0])
124+ pop := byID[10]
125+ if !pop.Popup || pop.Rect != (Rect{X: client.X - f.X + 3, Y: client.Y - f.Y + 4, W: 10, H: 8}) {
126+ t.Fatalf("popup %+v client %+v", pop, client)
127+ }
128+ for _, sep := range s.Separators {
129+ if sep.Rect.X < 0 || sep.Rect.Y < 0 || sep.Rect.X+sep.Rect.W > 100 || sep.Rect.Y+sep.Rect.H > 100 {
130+ t.Fatalf("separator outside the viewport: %+v", sep)
131+ }
132+ }
133+ c.configures.cw.reset()
134+ if c.captureScene(8) != nil {
135+ t.Fatal("scene without a captured workspace")
136+ }
137+}
138+
139+func TestCapturedPopupsSurviveButHiddenWindowsTakeNoInput(t *testing.T) {
140+ c, _ := hiddenCapture(t)
141+ c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
142+ c.popupOrder = append(c.popupOrder, 10)
143+ if _, _, ok := c.windowRect(10); ok {
144+ t.Fatal("captured popup is physically on screen")
145+ }
146+ if _, _, ok := c.windowRect(1); ok || c.visible(1) {
147+ t.Fatal("captured window is physically on screen")
148+ }
149+ if id, _, _ := c.hit(150, 100); id != 0 {
150+ t.Fatalf("pointer hit hidden window %d", id)
151+ }
152+ if sc := c.screens[0]; len(c.scenePopups(sc)) != 0 {
153+ t.Fatal("captured popup drawn on the physical scene")
154+ }
155+ if err := c.closeHiddenPopups(context.Background()); err != nil {
156+ t.Fatal(err)
157+ }
158+ if c.popups[10] == nil {
159+ t.Fatal("popup of a captured window dismissed")
160+ }
161+ c.configures.cw.reset()
162+ if err := c.closeHiddenPopups(context.Background()); err != nil {
163+ t.Fatal(err)
164+ }
165+ if c.popups[10] != nil {
166+ t.Fatal("popup of a hidden window kept after the session")
167+ }
168+}
169+
170+func TestCaptureWorkspaceReusesStorage(t *testing.T) {
171+ c, ws := hiddenCapture(t)
172+ sc := c.screens[0]
173+ cw := &c.configures.cw
174+ layoutAllocs := testing.AllocsPerRun(50, func() { _ = ws.Layout() })
175+ loadAllocs := testing.AllocsPerRun(50, func() { cw.load(sc, ws) })
176+ if loadAllocs > layoutAllocs {
177+ t.Fatalf("load allocates %v, more than the layout it wraps (%v)", loadAllocs, layoutAllocs)
178+ }
179+ if got := testing.AllocsPerRun(50, func() {
180+ cw.reset()
181+ cw.load(sc, ws)
182+ }); got > layoutAllocs {
183+ t.Fatalf("reset+load allocates %v, layout %v", got, layoutAllocs)
184+ }
185+}
186+
187+// While the captured workspace itself slides, the live (Active) session keeps
188+// tracking it in its own viewport: its windows stay captured and its popups
189+// stay, and both are forgotten when the session ends.
190+func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
191+ c, ws, _ := hiddenCaptureCommands(t)
192+ c.configures.cw.reset()
193+ c.capture = &captureSession{begin: ports.CaptureSessionBegin{ID: 1, Output: "A", Workspace: ws.ID}}
194+ c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
195+ c.popupOrder = append(c.popupOrder, 10)
196+ ws.motion = newMotion(viewSpring(10, 0), time.Time{})
197+ ctx := context.Background()
198+ if err := c.publish(ctx); err != nil {
199+ t.Fatal(err)
200+ }
201+ if v := c.capture.last; !v.hidden || !v.state.Active || v.state.Reason.Terminal() {
202+ t.Fatalf("session view %+v", v)
203+ }
204+ if !c.configures.cw.active() || c.popups[10] == nil {
205+ t.Fatal("sliding session lost its captured workspace or popup")
206+ }
207+ if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 {
208+ t.Fatalf("window during the slide %+v (sent %v)", v, ok)
209+ }
210+ ws.motion = nil
211+ if err := c.publish(ctx); err != nil {
212+ t.Fatal(err)
213+ }
214+ if !c.configures.cw.active() {
215+ t.Fatalf("captured workspace lost after the slide: %+v", c.capture.last.state)
216+ }
217+ // The session ends: the captured workspace is forgotten and its popup goes.
218+ c.dropCapture()
219+ if err := c.publish(ctx); err != nil {
220+ t.Fatal(err)
221+ }
222+ if c.configures.cw.active() || c.popups[10] != nil {
223+ t.Fatal("captured workspace or popup kept after the session")
224+ }
225+ if v, ok := c.configures.sent[1]; !ok || v.Captured || v.Visible || v.Width <= 0 {
226+ t.Fatalf("window after the session %+v (sent %v)", v, ok)
227+ }
228+}
229+
230+// In the overview the captured workspace's windows keep their real size and
231+// their physical state; nothing is sized as a thumbnail.
232+func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) {
233+ c, ws, _ := hiddenCaptureCommands(t)
234+ c.configures.cw.reset()
235+ m := c.screens[0].mon
236+ m.Focus(0)
237+ if m.Current() != ws {
238+ t.Fatal("workspace not current")
239+ }
240+ ctx := context.Background()
241+ if err := c.publish(ctx); err != nil {
242+ t.Fatal(err)
243+ }
244+ before := map[WindowID]ports.ConfigureWindow{}
245+ for _, p := range ws.Layout() {
246+ v, ok := c.configures.sent[p.ID]
247+ if !ok || v.Width <= 0 || !v.Visible || v.Captured {
248+ t.Fatalf("window %d before the overview %+v (sent %v)", p.ID, v, ok)
249+ }
250+ before[p.ID] = v
251+ }
252+ m.ToggleOverview()
253+ c.capture = &captureSession{begin: ports.CaptureSessionBegin{ID: 1, Output: "A", Workspace: ws.ID}}
254+ if err := c.publish(ctx); err != nil {
255+ t.Fatal(err)
256+ }
257+ if !c.configures.cw.active() {
258+ t.Fatal("overview did not track the captured workspace")
259+ }
260+ for _, p := range ws.Layout() {
261+ v, ok := c.configures.sent[p.ID]
262+ if !ok {
263+ t.Fatalf("window %d has no configure in the overview", p.ID)
264+ }
265+ // A preview keeps the client's real size and its physical state; it
266+ // is on screen, so it is not Captured.
267+ if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height || v.Width != c.clientRect(p).W || v.Height != c.clientRect(p).H {
268+ t.Fatalf("window %d configured %dx%d in the overview, real %dx%d", p.ID, v.Width, v.Height, c.clientRect(p).W, c.clientRect(p).H)
269+ }
270+ if !v.Visible || v.Captured || v.Activated != before[p.ID].Activated {
271+ t.Fatalf("window %d shown as a preview but %+v, was %+v", p.ID, v, before[p.ID])
272+ }
273+ }
274+}
M internal/core/configures.go +45 -3
@@ -11,6 +11,9 @@ type configureTarget struct {
1111 // floating window (native floats pick their own size).
1212 client Rect
1313 imposed bool
14+ // captureArea is the viewport of the workspace a capture session renders
15+ // off screen, when this window belongs to it (captureConfigure).
16+ captureArea Rect
1417 // realTiled sizes a visible tiled preview from its real client rect.
1518 realTiled bool
1619 }
@@ -20,6 +23,8 @@ type configureTarget struct {
2023 type configures struct {
2124 sent map[WindowID]ports.ConfigureWindow
2225 seen map[WindowID]bool
26+ // cw is the workspace a capture session renders off screen, if any.
27+ cw captureWorkspace
2328 }
2429
2530 func newConfigures() configures {
@@ -29,8 +34,45 @@ func newConfigures() configures {
2934 // next returns the configure for p at t and whether it must be sent. The
3035 // caller records it with mark once sent.
3136 func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow, bool) {
37+ s.seen[p.ID] = true
38+ old, ok := s.sent[p.ID]
39+ v := build(p, t, old, ok)
40+ return v, !ok || v != old
41+}
42+
43+// nextWithCapture is next for a window of a workspace a capture session
44+// renders off screen. p is its placement on the physical layout; cp is its
45+// real placement on its own workspace, laid out in the viewport
46+// t.captureArea (nil: not captured, exactly next).
47+//
48+// A hidden p is sized from cp like a shown window and stays physically
49+// invisible and deactivated (Visible false). Any other p (an overview
50+// preview) is configured as usual, keeping its real size; only when the
51+// display does not show it does it get Captured, so a capture that draws it
52+// does not see it suspended. Captured is set when cp is drawn in the
53+// viewport.
54+func (s *configures) nextWithCapture(p Placement, t configureTarget, cp *Placement) (ports.ConfigureWindow, bool) {
55+ if cp == nil {
56+ return s.next(p, t)
57+ }
3258 s.seen[p.ID] = true
3359 old, ok := s.sent[p.ID]
60+ var v ports.ConfigureWindow
61+ if p.Hidden {
62+ t.focused = false
63+ v = build(*cp, t, old, ok)
64+ v.Activated, v.Visible = false, false
65+ } else {
66+ v = build(p, t, old, ok)
67+ }
68+ v.Captured = !v.Visible && !cp.Hidden && cp.Rect.Overlaps(t.captureArea)
69+ return v, !ok || v != old
70+}
71+
72+// build is the configure for p at t given the last one sent, if any. It
73+// never sets Captured: only nextWithCapture does, so a window that leaves a
74+// capture session drops the flag with its next configure.
75+func build(p Placement, t configureTarget, old ports.ConfigureWindow, ok bool) ports.ConfigureWindow {
3476 var v ports.ConfigureWindow
3577 switch {
3678 case p.Hidden:
@@ -41,7 +83,7 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow
4183 if !ok {
4284 v = ports.ConfigureWindow{ID: p.ID, Floating: p.Floating}
4385 }
44- v.Activated, v.Visible, v.Output = false, false, t.output
86+ v.Activated, v.Visible, v.Output, v.Captured = false, false, t.output, false
4587 case p.Preview > 0:
4688 // A preview keeps its client's size: only its focus changes.
4789 v = old
@@ -60,14 +102,14 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow
60102 if t.realTiled {
61103 v.Width, v.Height = t.client.W, t.client.H
62104 }
63- v.Activated, v.Visible, v.Output = t.focused, onScreen(p, t.area), t.output
105+ v.Activated, v.Visible, v.Output, v.Captured = t.focused, onScreen(p, t.area), t.output, false
64106 default:
65107 v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: onScreen(p, t.area)}
66108 if v.Floating && !t.imposed {
67109 v.Width, v.Height = 0, 0
68110 }
69111 }
70- return v, !ok || v != old
112+ return v
71113 }
72114
73115 // mark records v as sent.
M internal/core/core.go +38 -1
@@ -111,6 +111,11 @@ type Core struct {
111111 // last position sent in the pointer's window.
112112 motionMsec uint32
113113 pointerAt [2]float64
114+ // capture is the live private capture session (capture_session.go);
115+ // captureC fires when its owner stopped pinging.
116+ capture *captureSession
117+ captureC <-chan time.Time
118+ captureStop func() bool
114119 }
115120
116121 func keyName(s string) string {
@@ -416,12 +421,21 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
416421 for id := range previous {
417422 c.windows.layerGone(id)
418423 }
424+ c.pruneRetained()
425+ c.clampCaptureKeyboard()
419426 for _, sc := range c.screens {
420427 sc.arrange()
421428 }
422429 }
423430
424431 func (c *Core) publish(ctx context.Context) error {
432+ capture, err := c.captureEvaluate(ctx)
433+ if err != nil {
434+ return err
435+ }
436+ // The workspace a session renders off screen keeps its real layout for
437+ // the configures, popups and CaptureScene of this publish.
438+ c.captureTrack(capture)
425439 if err := c.closeHiddenPopups(ctx); err != nil {
426440 return err
427441 }
@@ -493,7 +507,8 @@ func (c *Core) publish(ctx context.Context) error {
493507 t.client = c.clientRect(rp)
494508 }
495509 }
496- if v, send := c.configures.next(p, t); send {
510+ cp, t := c.captureConfigure(sc, p, t)
511+ if v, send := c.configures.nextWithCapture(p, t, cp); send {
497512 if err := c.command(ctx, v); err != nil {
498513 return err
499514 }
@@ -501,6 +516,10 @@ func (c *Core) publish(ctx context.Context) error {
501516 }
502517 }
503518 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
519+ if capture != nil && capture.scene != nil && sc.name() == capture.output {
520+ scene.Capture = capture.scene
521+ scene.CaptureScene = c.captureScene(scene.Seq)
522+ }
504523 scenes = append(scenes, scene)
505524 }
506525 c.configures.prune()
@@ -799,6 +818,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
799818
800819 func (c *Core) Run(ctx context.Context) error {
801820 defer c.stopFrame()
821+ defer c.stopCapture()
802822 // Startup commands run once per session; a config reload does not
803823 // run them again.
804824 for _, argv := range c.cfg.Startup {
@@ -823,6 +843,17 @@ func (c *Core) Run(ctx context.Context) error {
823843 continue
824844 }
825845 switch v := ev.(type) {
846+ case ports.CaptureSessionBegin:
847+ if c.captureBegin(ctx, v) != nil {
848+ return nil
849+ }
850+ case ports.CaptureSessionLayer:
851+ c.captureLayer(v)
852+ case ports.CaptureSessionPing:
853+ c.capturePing(v.ID)
854+ continue
855+ case ports.CaptureSessionEnd:
856+ c.captureEnd(v.ID)
826857 case ports.LayerChanged:
827858 c.layerChanged = true
828859 c.setLayers(v.Layers)
@@ -934,6 +965,12 @@ func (c *Core) Run(ctx context.Context) error {
934965 return nil
935966 }
936967 continue
968+ case <-c.captureC:
969+ c.captureExpired()
970+ if c.publish(ctx) != nil {
971+ return nil
972+ }
973+ continue
937974 case ev, ok := <-c.ch.Output:
938975 if !ok {
939976 c.ch.Output = nil
M internal/core/layers.go +1 -1
@@ -99,7 +99,7 @@ func hasFullscreen(sc *screen) bool {
9999 // keyboard exclusively (a locker, a launcher) still shows: it has the
100100 // keyboard and must be seen.
101101 func shown(sc *screen, l ports.SceneLayer) bool {
102- return l.Layer == ports.LayerBackground || !hasFullscreen(sc) || sc.exclusiveKeyboard(l.ID)
102+ return l.Layer == ports.LayerBackground || !hasFullscreen(sc) || sc.exclusiveKeyboard(l.ID) || sc.capture[l.ID]
103103 }
104104
105105 // exclusiveKeyboard reports whether layer surface id takes the keyboard
M internal/core/popup.go +6 -2
@@ -173,7 +173,7 @@ func (c *Core) peeking(id WindowID) bool {
173173 // placePopup places a new or repositioned popup and configures it; a popup
174174 // whose parent is not on screen is dismissed.
175175 func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error {
176- sc, pr, ok := c.windowRect(v.Parent)
176+ sc, pr, physical, ok := c.popupRect(v.Parent)
177177 if !ok {
178178 if c.popups[v.ID] != nil {
179179 return c.closePopup(ctx, v.ID)
@@ -184,6 +184,9 @@ func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error {
184184 o := sc.mon.Output()
185185 if !c.onLayer(c.popupRoot(v.Parent)) {
186186 o = sc.mon.Frame()
187+ if !physical {
188+ o = c.configures.cw.frame // the captured workspace's viewport
189+ }
187190 }
188191 bounds := Rect{X: o.X - pr.X, Y: o.Y - pr.Y, W: o.W, H: o.H}
189192 p := c.popups[v.ID]
@@ -249,7 +252,8 @@ func (c *Core) closeHiddenPopups(ctx context.Context) error {
249252 if c.popups[id] == nil {
250253 continue
251254 }
252- if _, _, ok := c.windowRect(id); !ok {
255+ // A popup of a window a capture session renders off screen stays.
256+ if _, _, _, ok := c.popupRect(id); !ok {
253257 if err := c.closePopup(ctx, id); err != nil {
254258 return err
255259 }
M internal/core/screens.go +7 -0
@@ -18,6 +18,9 @@ type screen struct {
1818 off bool // turned off by a client (output power management)
1919 layers []ports.LayerSurface
2020 placed []ports.SceneLayer
21+ // capture holds the layer surfaces of the active capture session on
22+ // this output: shown over a fullscreen window (capture_session.go).
23+ capture map[WindowID]bool
2124 }
2225
2326 func (s *screen) name() string { return s.info.Name }
@@ -213,6 +216,10 @@ func (c *Core) removeScreen(name string) {
213216 if i < 0 {
214217 return
215218 }
219+ // A capture session's off-screen workspace of this output goes with it.
220+ if c.configures.cw.sc == c.screens[i] {
221+ c.configures.cw.reset()
222+ }
216223 if len(c.screens) == 1 {
217224 c.screens[0].info = ports.OutputInfo{}
218225 c.screens[0].layers = nil
M internal/core/workspaces.go +1 -1
@@ -26,7 +26,7 @@ func (c *Core) publishWorkspaces() {
2626 if name == "" {
2727 name = strconv.Itoa(i + 1)
2828 }
29- out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name})
29+ out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name, Frame: w.Output})
3030 }
3131 snapshot.Outputs = append(snapshot.Outputs, out)
3232 }
A internal/ports/capture_session.go +161 -0
@@ -0,0 +1,161 @@
1+package ports
2+
3+import "time"
4+
5+// Private capture sessions (neferwl_capture_manager_v1, see
6+// internal/adapters/wayland/capturesession/neferwl-capture-v1.xml).
7+//
8+// A session is opened by one client connection (the capture connection) for
9+// one workspace of one output. While core reports it Active, the layer
10+// surfaces another connection attached to it (the HUD, authorised by token
11+// and peer UID) stay visible over a fullscreen window and are left out of
12+// clean captures; screen captures requested by the session's own connection
13+// are tagged Clean. Every other capture keeps the HUD and the native border.
14+//
15+// Only one session is live in the compositor. Core owns the session state;
16+// wayland only relays requests and tells clients what core decided.
17+
18+const (
19+ // MaxCaptureSessionLayers bounds the layer surfaces attached to a session.
20+ MaxCaptureSessionLayers = 4
21+ // MaxCaptureExcluded bounds SceneCapture.Excluded: attached layers and
22+ // their popup descendants.
23+ MaxCaptureExcluded = 128
24+ // CaptureSessionTimeout is how long core lets a session live without a
25+ // CaptureSessionPing (the explicit keep-alive) before it stops it.
26+ CaptureSessionTimeout = 5 * time.Second
27+ // CaptureBorderWidth and CaptureBorderColor are the native compositor
28+ // border of a recording session (CaptureSessionBegin.Record), in logical
29+ // pixels and #rrggbb.
30+ CaptureBorderWidth = 2
31+ CaptureBorderColor = "#ff3b30"
32+)
33+
34+// CaptureReason says why a session is not active. The zero value is none.
35+type CaptureReason string
36+
37+const (
38+ CaptureReasonNone CaptureReason = ""
39+ // Every reason ends the session: core forgets it and wayland stops it.
40+ CaptureReasonOutputGone CaptureReason = "output-gone"
41+ CaptureReasonOutputOff CaptureReason = "output-off"
42+ CaptureReasonPingTimeout CaptureReason = "ping-timeout"
43+ CaptureReasonWorkspaceGone CaptureReason = "workspace-gone"
44+ CaptureReasonBusy CaptureReason = "busy" // another session is live
45+ CaptureReasonInvalidRegion CaptureReason = "invalid-region"
46+ CaptureReasonUnauthorized CaptureReason = "unauthorized" // wayland only: peer credentials unreadable or not the compositor's user
47+ // TooManyExcluded: the attached layers and their popups exceed
48+ // MaxCaptureExcluded; a capture could not leave them all out (fail closed).
49+ CaptureReasonTooManyExcluded CaptureReason = "too-many-excluded"
50+)
51+
52+// Terminal reports whether a state with this reason ends the session.
53+func (r CaptureReason) Terminal() bool { return r != CaptureReasonNone }
54+
55+// CaptureSessionBegin carries wayland → core a new session. There are two
56+// target kinds:
57+// - Workspace 0 is an output target: whatever the output shows, bars and
58+// margins included, overview too. A zero Region means the whole output;
59+// a non-zero one is clipped to the output.
60+// - Workspace is a ports.WorkspaceInfo.ID: that workspace's frame
61+// (its own viewport, no bars). A zero Region means the whole frame; a
62+// non-zero one is clipped to the frame. The workspace may be on screen or
63+// not, and the overview does not matter: one that is not drawn as itself
64+// is rendered for capture only (Scene.CaptureScene). It ends with
65+// CaptureReasonWorkspaceGone when it is removed or moves to another
66+// output; sliding never pauses it.
67+//
68+// Region is output-local logical pixels. Record asks for the native border.
69+// ID is chosen by wayland and unique for the process lifetime.
70+type CaptureSessionBegin struct {
71+ ID uint64
72+ Output string
73+ Workspace uint64
74+ Region Rect
75+ Record bool
76+}
77+
78+func (CaptureSessionBegin) clientEvent() {}
79+
80+// CaptureSessionLayer carries wayland → core a layer surface the session's
81+// authorised HUD attached (or detached, when it was destroyed). Layer is the
82+// LayerSurface.ID, known before the surface maps. Wayland has already checked
83+// the layer is top or overlay and that it never takes the keyboard
84+// exclusively; at most MaxCaptureSessionLayers are attached.
85+type CaptureSessionLayer struct {
86+ ID uint64
87+ Layer WindowID
88+ Attached bool
89+}
90+
91+func (CaptureSessionLayer) clientEvent() {}
92+
93+// CaptureSessionPing carries wayland → core the session owner's keep-alive.
94+type CaptureSessionPing struct{ ID uint64 }
95+
96+func (CaptureSessionPing) clientEvent() {}
97+
98+// CaptureSessionEnd carries wayland → core an explicit stop or the owner's
99+// disconnect. Unknown IDs are ignored.
100+type CaptureSessionEnd struct{ ID uint64 }
101+
102+func (CaptureSessionEnd) clientEvent() {}
103+
104+// CaptureSessionState carries core → wayland what core decided (wayland relays it as neferwl_capture_session_v1.state), on every
105+// change of it. Rect is the clipped target in output-local logical pixels and
106+// Workspace the real workspace ID. Active means captures are being served
107+// (and the scene carries SceneCapture); a non-empty Reason with Active false
108+// explains it, and every Reason ends the session. A live session is always
109+// Active: a slide or swipe does not pause it (a workspace target's geometry is
110+// its own viewport, unaffected), and a session that is lost is stopped, never
111+// paused.
112+//
113+// Revision is the session's revision fence, from 1. Core bumps it whenever
114+// what a clean capture must do changes: the start, an attached or detached
115+// layer, an excluded popup or the target. The scene carrying that
116+// change has the same SceneCapture.Revision. Layers lists the attached layer
117+// surfaces core knows (at most MaxCaptureSessionLayers): wayland confirms an
118+// attach to the client only once a state lists the layer.
119+//
120+// Wayland serves a clean capture only for a session Active in the last state
121+// it saw, stamps CaptureRequest.CaptureRevision with that state's Revision,
122+// and a renderer refuses the request unless its scene's Capture.Revision is
123+// at least that: a scene published before the change is never used, so no
124+// stale exclusion list can leak a HUD.
125+type CaptureSessionState struct {
126+ ID uint64
127+ Output string
128+ Rect Rect
129+ Workspace uint64
130+ Active bool
131+ Reason CaptureReason
132+ Revision uint64
133+ Layers []WindowID
134+}
135+
136+func (CaptureSessionState) clientCommand() {}
137+
138+// SceneCapture carries core → renderer the active session on one output's
139+// scene (Scene.Capture; nil when none). TargetRect is output-local logical:
140+// the clipped region of the output, or of the workspace frame. Excluded lists
141+// the attached layer surfaces and their popup descendants (at most
142+// MaxCaptureExcluded), which clean captures leave out. BorderWidth and
143+// BorderColor are the native border of a recording session, zero and empty
144+// otherwise; it is drawn inside TargetRect, on its edge, in every capture
145+// that is not Clean, and only when the target is drawn on screen: a
146+// workspace rendered for capture only (Scene.CaptureScene) has none.
147+// Workspace is the real workspace ID, 0 for an output target. For such a
148+// hidden workspace TargetRect is relative to the origin of CaptureScene (the
149+// workspace viewport), the scene that capture renders; otherwise it is
150+// output-local.
151+type SceneCapture struct {
152+ Session uint64
153+ TargetRect Rect
154+ Excluded []WindowID
155+ BorderWidth int
156+ BorderColor string
157+ Workspace uint64
158+ // Revision is the CaptureSessionState.Revision this scene carries; a
159+ // clean CaptureRequest needs Revision >= its CaptureRevision.
160+ Revision uint64
161+}
M internal/ports/messages.go +28 -0
@@ -15,6 +15,16 @@ type CaptureRequest struct {
1515 Dst SHMBuffer
1616 Width, Height, Stride int
1717 Format uint32
18+ // Clean asks for a capture without the private session's HUD layers
19+ // and native border (SceneCapture). Wayland sets it, with Session, only
20+ // on requests of the connection that owns the active session; every
21+ // other capture is false/0 and sees the scene as shown.
22+ Clean bool
23+ Session uint64
24+ // CaptureRevision is the session Revision last confirmed by core
25+ // (CaptureSessionState); a renderer fails a Clean request whose scene
26+ // has Capture nil or Capture.Revision below it. 0 when not Clean.
27+ CaptureRevision uint64
1828 }
1929
2030 // CaptureDone is attempted once after the output closes the destination.
@@ -128,6 +138,10 @@ type WorkspaceInfo struct {
128138 Name string
129139 Index int // zero-based vertical coordinate
130140 Active, Hidden bool
141+ // Frame is the workspace viewport in output-local logical pixels
142+ // (Monitor.Frame geometry: the whole output unless the workspace has a
143+ // size override). It is where a capture session of it lies.
144+ Frame Rect
131145 }
132146
133147 // WindowAppID carries wayland → core an app ID set after the window mapped.
@@ -486,6 +500,10 @@ type OutputPresented struct {
486500 Flip *FlipInfo
487501 Shown, Queued uint64
488502 Seen map[WindowID]uint64
503+ // ChildReads is the oldest content Seq per window still read by a child
504+ // renderer on a separate device. Unlike Seen, these holds never time out:
505+ // they end only with a completed render or device shutdown.
506+ ChildReads map[WindowID]uint64
489507 }
490508
491509 // OutputFormats carries output → wayland the dmabuf formats an output can
@@ -583,6 +601,9 @@ type ConfigureWindow struct {
583601 // hidden workspace, scrolled off, behind a maximized column or a
584602 // hidden float. An invisible window is suspended and throttled.
585603 Visible bool
604+ // Captured marks a window drawn only for a capture session (its
605+ // workspace is not on screen): Visible stays the physical truth.
606+ Captured bool
586607 }
587608
588609 func (ConfigureWindow) clientCommand() {}
@@ -793,6 +814,13 @@ type Scene struct {
793814 // Window popups are drawn after the windows, layer popups
794815 // (SceneWindow.OverLayers) last, over every layer.
795816 Layers []SceneLayer
817+ // Capture is the active private capture session on this output, nil
818+ // when none (capture_session.go).
819+ Capture *SceneCapture
820+ // CaptureScene is the workspace of the active session drawn for
821+ // capture only (a workspace that is not on screen). Only the root scene
822+ // carries it; CaptureScene.Capture and .CaptureScene are nil.
823+ CaptureScene *Scene
796824 }
797825
798826 // Shows reports whether the scene draws the surface of id: only its

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close