detached 471cea3

feat(capture): support grim and slurp through screencopy and image copy capture (#5)

* build(deps): bump purego-libwayland to v0.4.0

* feat(capture): compose and copy output frames into shm

* feat(wayland): expose screencopy and output image capture

* fix(wayland): enforce capture protocol lifecycle and errors

* fix(capture): close destinations and drain requests on shutdown

* docs(capture): clarify verified screenshots and cursor limitation

* fix(capture): keep frames alive past their session and drain requests after the server stops

Destroying an ext session no longer drops its pending frame's reply, as the protocol requires. Output resizes resend the full constraint batch (formats, size, done). Queued capture requests are drained once all workers, including the wayland producer, have stopped.

* fix(headless): stop started outputs when a later output fails to start

* feat(capture): log capture requests, forced frames and results

Debug logs trace each capture (request, forced composed frame, result with duration). Failed captures, a full request queue and fd dup errors are logged at info or warn level.
commit
471cea393b5e8bd7505e5f7ba3f5e8c619d44160
author
bnema · · 2026-09-27 04:53 UTC
committer
GitHub · · 2026-09-27 04:53 UTC
parent
e55c0e7
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
28 files · +1796 -54
M README.md +3 -1
@@ -183,6 +183,8 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots
183183 WAYLAND_DISPLAY=<name from the log> foot
184184 ```
185185
186+Screenshots through `grim` are verified with `zwlr_screencopy_v1` and `ext_image_copy_capture_v1`. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor.
187+
186188 `/tmp/neferwl-shots/latest.png` shows the current frame. `--timeout 5s` stops NeferWL after 5 seconds.
187189
188190 `--input <path>`, or `--input -` for stdin, plays an input script. One command per line:
@@ -193,7 +195,7 @@ WAYLAND_DISPLAY=<name from the log> foot
193195 - `move <x> <y>` (output coordinates)
194196 - `click [left|right|middle]`, `down <button>`, `up <button>`
195197
196-The headless backend does not draw the cursor. After a layout change, pointer focus updates on the next move.
198+The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move.
197199
198200 ## Why not Rust?
199201
@@ -183,6 +183,8 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots
183 WAYLAND_DISPLAY=<name from the log> foot183 WAYLAND_DISPLAY=<name from the log> foot
184 ```184 ```
185 185
186+Screenshots through `grim` are verified with `zwlr_screencopy_v1` and `ext_image_copy_capture_v1`. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor.
187+
186 `/tmp/neferwl-shots/latest.png` shows the current frame. `--timeout 5s` stops NeferWL after 5 seconds.188 `/tmp/neferwl-shots/latest.png` shows the current frame. `--timeout 5s` stops NeferWL after 5 seconds.
187 189
188 `--input <path>`, or `--input -` for stdin, plays an input script. One command per line:190 `--input <path>`, or `--input -` for stdin, plays an input script. One command per line:
@@ -193,7 +195,7 @@ WAYLAND_DISPLAY=<name from the log> foot
193 - `move <x> <y>` (output coordinates)195 - `move <x> <y>` (output coordinates)
194 - `click [left|right|middle]`, `down <button>`, `up <button>`196 - `click [left|right|middle]`, `down <button>`, `up <button>`
195 197
196-The headless backend does not draw the cursor. After a layout change, pointer focus updates on the next move.198+The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move.
197 199
198 ## Why not Rust?200 ## Why not Rust?
199 201
M go.mod +1 -1
@@ -4,7 +4,7 @@ go 1.27
44
55 require (
66 github.com/bnema/purego v0.11.0-bnema.4
7- github.com/bnema/purego-libwayland v0.2.1-0.20260926175222-2747fb8b4d43
7+ github.com/bnema/purego-libwayland v0.4.0
88 github.com/bnema/purego-vulkan v0.4.1
99 github.com/bnema/wlturbo v0.2.0
1010 github.com/bnema/zerowrap v1.4.1
@@ -4,7 +4,7 @@ go 1.27
4 4
5 require (5 require (
6 github.com/bnema/purego v0.11.0-bnema.46 github.com/bnema/purego v0.11.0-bnema.4
7- github.com/bnema/purego-libwayland v0.2.1-0.20260926175222-2747fb8b4d437+ github.com/bnema/purego-libwayland v0.4.0
8 github.com/bnema/purego-vulkan v0.4.18 github.com/bnema/purego-vulkan v0.4.1
9 github.com/bnema/wlturbo v0.2.09 github.com/bnema/wlturbo v0.2.0
10 github.com/bnema/zerowrap v1.4.110 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -4 generated file: collapsed
A internal/adapters/capture/capture.go +75 -0
@@ -0,0 +1,75 @@
1+// Package capture transfers each request to an output: the output closes its
2+// destination fd exactly once and attempts one reply, never blocking after
3+// its context is done.
4+package capture
5+
6+import (
7+ "context"
8+ "errors"
9+ "fmt"
10+ "time"
11+
12+ "github.com/bnema/neferwl/internal/ports"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func reply(ctx context.Context, req ports.CaptureRequest, err error, replies chan<- ports.CaptureDone) {
17+ if req.Dst.File != nil {
18+ err = errors.Join(err, req.Dst.File.Close())
19+ }
20+ // A nil channel is allowed: cancellation still releases the sender.
21+ select {
22+ case <-ctx.Done():
23+ case replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: monotonicNow()}:
24+ }
25+}
26+
27+// monotonicNow represents CLOCK_MONOTONIC in the Time seconds/nanoseconds fields.
28+func monotonicNow() time.Time {
29+ var ts unix.Timespec
30+ if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts); err != nil {
31+ return time.Time{}
32+ }
33+ return time.Unix(ts.Sec, ts.Nsec)
34+}
35+
36+func Fail(ctx context.Context, req ports.CaptureRequest, err error, replies chan<- ports.CaptureDone) {
37+ reply(ctx, req, err, replies)
38+}
39+
40+func Write(ctx context.Context, req ports.CaptureRequest, r ports.Renderer, replies chan<- ports.CaptureDone) {
41+ var err error
42+ var data []byte
43+ defer func() {
44+ if data != nil {
45+ err = errors.Join(err, unix.Munmap(data))
46+ }
47+ reply(ctx, req, err, replies)
48+ }()
49+ if req.Dst.File == nil || req.Width <= 0 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 {
50+ err = fmt.Errorf("invalid capture destination")
51+ return
52+ }
53+ size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + int64(req.Width)*4
54+ if size <= 0 || size > int64(^uint(0)>>1) || size > 1<<30 {
55+ err = fmt.Errorf("capture too large")
56+ return
57+ }
58+ st, e := req.Dst.File.Stat()
59+ if e != nil {
60+ err = e
61+ return
62+ }
63+ if st.Size() < size {
64+ err = fmt.Errorf("capture buffer truncated")
65+ return
66+ }
67+ // Map only for the duration of this capture; the renderer copies BGRA rows
68+ // directly from its readback buffer into the selected shm region.
69+ data, e = unix.Mmap(int(req.Dst.File.Fd()), 0, int(size), unix.PROT_WRITE, unix.MAP_SHARED)
70+ if e != nil {
71+ err = e
72+ return
73+ }
74+ err = r.Capture(req.Region, data[req.Dst.Offset:], req.Stride)
75+}
@@ -0,0 +1,75 @@
1+// Package capture transfers each request to an output: the output closes its
2+// destination fd exactly once and attempts one reply, never blocking after
3+// its context is done.
4+package capture
5+
6+import (
7+ "context"
8+ "errors"
9+ "fmt"
10+ "time"
11+
12+ "github.com/bnema/neferwl/internal/ports"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func reply(ctx context.Context, req ports.CaptureRequest, err error, replies chan<- ports.CaptureDone) {
17+ if req.Dst.File != nil {
18+ err = errors.Join(err, req.Dst.File.Close())
19+ }
20+ // A nil channel is allowed: cancellation still releases the sender.
21+ select {
22+ case <-ctx.Done():
23+ case replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: monotonicNow()}:
24+ }
25+}
26+
27+// monotonicNow represents CLOCK_MONOTONIC in the Time seconds/nanoseconds fields.
28+func monotonicNow() time.Time {
29+ var ts unix.Timespec
30+ if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts); err != nil {
31+ return time.Time{}
32+ }
33+ return time.Unix(ts.Sec, ts.Nsec)
34+}
35+
36+func Fail(ctx context.Context, req ports.CaptureRequest, err error, replies chan<- ports.CaptureDone) {
37+ reply(ctx, req, err, replies)
38+}
39+
40+func Write(ctx context.Context, req ports.CaptureRequest, r ports.Renderer, replies chan<- ports.CaptureDone) {
41+ var err error
42+ var data []byte
43+ defer func() {
44+ if data != nil {
45+ err = errors.Join(err, unix.Munmap(data))
46+ }
47+ reply(ctx, req, err, replies)
48+ }()
49+ if req.Dst.File == nil || req.Width <= 0 || req.Height <= 0 || req.Stride < req.Width*4 || req.Region.Dx() != req.Width || req.Region.Dy() != req.Height || req.Dst.Offset < 0 {
50+ err = fmt.Errorf("invalid capture destination")
51+ return
52+ }
53+ size := int64(req.Dst.Offset) + int64(req.Height-1)*int64(req.Stride) + int64(req.Width)*4
54+ if size <= 0 || size > int64(^uint(0)>>1) || size > 1<<30 {
55+ err = fmt.Errorf("capture too large")
56+ return
57+ }
58+ st, e := req.Dst.File.Stat()
59+ if e != nil {
60+ err = e
61+ return
62+ }
63+ if st.Size() < size {
64+ err = fmt.Errorf("capture buffer truncated")
65+ return
66+ }
67+ // Map only for the duration of this capture; the renderer copies BGRA rows
68+ // directly from its readback buffer into the selected shm region.
69+ data, e = unix.Mmap(int(req.Dst.File.Fd()), 0, int(size), unix.PROT_WRITE, unix.MAP_SHARED)
70+ if e != nil {
71+ err = e
72+ return
73+ }
74+ err = r.Capture(req.Region, data[req.Dst.Offset:], req.Stride)
75+}
A internal/adapters/capture/capture_test.go +52 -0
@@ -0,0 +1,52 @@
1+package capture
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ "golang.org/x/sys/unix"
11+)
12+
13+func TestFailClosesBeforeReplyAndUsesMonotonic(t *testing.T) {
14+ f, err := os.CreateTemp(t.TempDir(), "capture")
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ replies := make(chan ports.CaptureDone, 1)
19+ Fail(context.Background(), ports.CaptureRequest{Dst: ports.SHMBuffer{File: f}}, nil, replies)
20+ done := <-replies
21+ if _, err := f.Stat(); err == nil {
22+ t.Fatal("fd open at reply")
23+ }
24+ var now unix.Timespec
25+ if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil {
26+ t.Fatal(err)
27+ }
28+ if d := time.Unix(now.Sec, now.Nsec).Sub(done.Time); d < 0 || d > time.Second {
29+ t.Fatalf("non-monotonic capture time: %v", d)
30+ }
31+}
32+
33+func TestFailCancelledWithFullReplies(t *testing.T) {
34+ ctx, cancel := context.WithCancel(context.Background())
35+ cancel()
36+ replies := make(chan ports.CaptureDone, 1)
37+ replies <- ports.CaptureDone{}
38+ f, err := os.CreateTemp(t.TempDir(), "capture")
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ done := make(chan struct{})
43+ go func() { Fail(ctx, ports.CaptureRequest{Dst: ports.SHMBuffer{File: f}}, nil, replies); close(done) }()
44+ select {
45+ case <-done:
46+ case <-time.After(2 * time.Second):
47+ t.Fatal("blocked")
48+ }
49+ if _, err := f.Stat(); err == nil {
50+ t.Fatal("fd still open")
51+ }
52+}
@@ -0,0 +1,52 @@
1+package capture
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ "golang.org/x/sys/unix"
11+)
12+
13+func TestFailClosesBeforeReplyAndUsesMonotonic(t *testing.T) {
14+ f, err := os.CreateTemp(t.TempDir(), "capture")
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ replies := make(chan ports.CaptureDone, 1)
19+ Fail(context.Background(), ports.CaptureRequest{Dst: ports.SHMBuffer{File: f}}, nil, replies)
20+ done := <-replies
21+ if _, err := f.Stat(); err == nil {
22+ t.Fatal("fd open at reply")
23+ }
24+ var now unix.Timespec
25+ if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil {
26+ t.Fatal(err)
27+ }
28+ if d := time.Unix(now.Sec, now.Nsec).Sub(done.Time); d < 0 || d > time.Second {
29+ t.Fatalf("non-monotonic capture time: %v", d)
30+ }
31+}
32+
33+func TestFailCancelledWithFullReplies(t *testing.T) {
34+ ctx, cancel := context.WithCancel(context.Background())
35+ cancel()
36+ replies := make(chan ports.CaptureDone, 1)
37+ replies <- ports.CaptureDone{}
38+ f, err := os.CreateTemp(t.TempDir(), "capture")
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ done := make(chan struct{})
43+ go func() { Fail(ctx, ports.CaptureRequest{Dst: ports.SHMBuffer{File: f}}, nil, replies); close(done) }()
44+ select {
45+ case <-done:
46+ case <-time.After(2 * time.Second):
47+ t.Fatal("blocked")
48+ }
49+ if _, err := f.Stat(); err == nil {
50+ t.Fatal("fd still open")
51+ }
52+}
M internal/adapters/drm/commit_test.go +3 -3
@@ -171,7 +171,7 @@ func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {
171171 ctx, cancel := context.WithCancel(context.Background())
172172 done := make(chan error, 1)
173173 go func() {
174- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1))
174+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil)
175175 }()
176176 count := func(flags uint32) int {
177177 commitMu.Lock()
@@ -287,7 +287,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) {
287287 ctx, cancel := context.WithCancel(context.Background())
288288 done := make(chan error, 1)
289289 go func() {
290- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, load, make(chan bool), scenes, contents, nil, presented)
290+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, load, make(chan bool), scenes, contents, nil, presented, nil, nil)
291291 }()
292292 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
293293 var frame commitRec
@@ -370,7 +370,7 @@ func TestRunClosesFrameFences(t *testing.T) {
370370 ctx, cancel := context.WithCancel(context.Background())
371371 done := make(chan error, 1)
372372 go func() {
373- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, scenes, nil, nil, presented)
373+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, scenes, nil, nil, presented, nil, nil)
374374 }()
375375 count := func() int {
376376 commitMu.Lock()
@@ -171,7 +171,7 @@ func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {
171 ctx, cancel := context.WithCancel(context.Background())171 ctx, cancel := context.WithCancel(context.Background())
172 done := make(chan error, 1)172 done := make(chan error, 1)
173 go func() {173 go func() {
174- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1))174+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil)
175 }()175 }()
176 count := func(flags uint32) int {176 count := func(flags uint32) int {
177 commitMu.Lock()177 commitMu.Lock()
@@ -287,7 +287,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) {
287 ctx, cancel := context.WithCancel(context.Background())287 ctx, cancel := context.WithCancel(context.Background())
288 done := make(chan error, 1)288 done := make(chan error, 1)
289 go func() {289 go func() {
290- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, load, make(chan bool), scenes, contents, nil, presented)290+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, load, make(chan bool), scenes, contents, nil, presented, nil, nil)
291 }()291 }()
292 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}292 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
293 var frame commitRec293 var frame commitRec
@@ -370,7 +370,7 @@ func TestRunClosesFrameFences(t *testing.T) {
370 ctx, cancel := context.WithCancel(context.Background())370 ctx, cancel := context.WithCancel(context.Background())
371 done := make(chan error, 1)371 done := make(chan error, 1)
372 go func() {372 go func() {
373- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, scenes, nil, nil, presented)373+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, scenes, nil, nil, presented, nil, nil)
374 }()374 }()
375 count := func() int {375 count := func() int {
376 commitMu.Lock()376 commitMu.Lock()
M internal/adapters/drm/output.go +51 -3
@@ -11,6 +11,8 @@ import (
1111 "time"
1212 "unsafe"
1313
14+ "github.com/bnema/neferwl/internal/adapters/capture"
15+
1416 "github.com/bnema/neferwl/internal/adapters/syncfile"
1517 "github.com/bnema/neferwl/internal/ports"
1618 "github.com/bnema/zerowrap"
@@ -836,7 +838,7 @@ func (o *Output) Close() {
836838
837839 // Run renders scenes and commits them until ctx ends. active reports seat
838840 // enable/disable. What the output shows and read is reported on presented.
839-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented) error {
841+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) error {
840842 r, err := newRenderer(o.Width(), o.Height())
841843 if err != nil {
842844 return fmt.Errorf("create renderer: %w", err)
@@ -869,6 +871,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
869871 var want ports.CursorChange
870872 cursorScale := -1.0 // not loaded yet
871873 frame := 0
874+ var requests []ports.CaptureRequest
875+ ctx, cancelCaptures := context.WithCancel(ctx)
876+ defer func() {
877+ cancelCaptures()
878+ for _, q := range requests {
879+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
880+ }
881+ for {
882+ select {
883+ case q := <-captures:
884+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
885+ default:
886+ return
887+ }
888+ }
889+ }()
872890 stats := time.NewTicker(10 * time.Second)
873891 defer stats.Stop()
874892 // seen is the latest content Seq per window. It is reported only while
@@ -929,6 +947,13 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
929947 stateDirty = true
930948 case <-ctx.Done():
931949 return nil
950+ case q := <-captures:
951+ if !enabled || o.off || o.wantOff {
952+ capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
953+ } else {
954+ requests = append(requests, q)
955+ dirty = haveScene
956+ }
932957 case on := <-active:
933958 // Modeset on every enable: a fast disable+enable can coalesce to one true.
934959 if on {
@@ -1000,6 +1025,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10001025 }
10011026 ev.Msg("stats")
10021027 }
1028+ if len(requests) > 0 && (!enabled || o.off || o.wantOff) {
1029+ for _, q := range requests {
1030+ capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1031+ }
1032+ requests = nil
1033+ }
10031034 if !enabled || o.pending {
10041035 continue
10051036 }
@@ -1036,13 +1067,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10361067 // shows is what the frame puts on screen: presentation feedback
10371068 // of windows it does not draw is discarded, not presented.
10381069 f := pendingFrame{shows: shownBy(scene, seen)}
1039- fb, c := o.scanoutFrame(scene, surfaces)
1070+ var fb uint32
1071+ var c ports.SurfaceContent
1072+ forceCompose := len(requests) > 0
1073+ if !forceCompose {
1074+ fb, c = o.scanoutFrame(scene, surfaces)
1075+ }
10401076 direct := false
10411077 if fb != 0 {
10421078 direct, err = o.commitScanout(fb, c, pendingFrame{shows: map[ports.WindowID]uint64{c.ID: seen[c.ID]}})
10431079 }
10441080 if !direct {
1045- ov, composed := o.overlayFrame(scene, surfaces)
1081+ var ov overlayWin
1082+ composed := scene
1083+ if !forceCompose {
1084+ ov, composed = o.overlayFrame(scene, surfaces)
1085+ }
10461086 r.UseTarget(o.back)
10471087 done, rerr := r.Render(composed, surfaces)
10481088 if rerr != nil {
@@ -1060,6 +1100,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10601100 return fmt.Errorf("render frame: %w", rerr)
10611101 }
10621102 }
1103+ if len(requests) > 0 {
1104+ // Scanout and the overlay plane were skipped for this frame.
1105+ o.log.Debug().Str("connector", o.conn.name).Int("captures", len(requests)).Msg("capture frame composed")
1106+ }
1107+ for _, q := range requests {
1108+ capture.Write(ctx, q, r, captured)
1109+ }
1110+ requests = nil
10631111 // The overlay buffer is on screen like a scanned-out one: it
10641112 // is reported shown, so it is not released under the plane.
10651113 f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true
@@ -11,6 +11,8 @@ import (
11 "time"11 "time"
12 "unsafe"12 "unsafe"
13 13
14+ "github.com/bnema/neferwl/internal/adapters/capture"
15+
14 "github.com/bnema/neferwl/internal/adapters/syncfile"16 "github.com/bnema/neferwl/internal/adapters/syncfile"
15 "github.com/bnema/neferwl/internal/ports"17 "github.com/bnema/neferwl/internal/ports"
16 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
@@ -836,7 +838,7 @@ func (o *Output) Close() {
836 838
837 // Run renders scenes and commits them until ctx ends. active reports seat839 // Run renders scenes and commits them until ctx ends. active reports seat
838 // enable/disable. What the output shows and read is reported on presented.840 // enable/disable. What the output shows and read is reported on presented.
839-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented) error {841+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) error {
840 r, err := newRenderer(o.Width(), o.Height())842 r, err := newRenderer(o.Width(), o.Height())
841 if err != nil {843 if err != nil {
842 return fmt.Errorf("create renderer: %w", err)844 return fmt.Errorf("create renderer: %w", err)
@@ -869,6 +871,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
869 var want ports.CursorChange871 var want ports.CursorChange
870 cursorScale := -1.0 // not loaded yet872 cursorScale := -1.0 // not loaded yet
871 frame := 0873 frame := 0
874+ var requests []ports.CaptureRequest
875+ ctx, cancelCaptures := context.WithCancel(ctx)
876+ defer func() {
877+ cancelCaptures()
878+ for _, q := range requests {
879+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
880+ }
881+ for {
882+ select {
883+ case q := <-captures:
884+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
885+ default:
886+ return
887+ }
888+ }
889+ }()
872 stats := time.NewTicker(10 * time.Second)890 stats := time.NewTicker(10 * time.Second)
873 defer stats.Stop()891 defer stats.Stop()
874 // seen is the latest content Seq per window. It is reported only while892 // seen is the latest content Seq per window. It is reported only while
@@ -929,6 +947,13 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
929 stateDirty = true947 stateDirty = true
930 case <-ctx.Done():948 case <-ctx.Done():
931 return nil949 return nil
950+ case q := <-captures:
951+ if !enabled || o.off || o.wantOff {
952+ capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
953+ } else {
954+ requests = append(requests, q)
955+ dirty = haveScene
956+ }
932 case on := <-active:957 case on := <-active:
933 // Modeset on every enable: a fast disable+enable can coalesce to one true.958 // Modeset on every enable: a fast disable+enable can coalesce to one true.
934 if on {959 if on {
@@ -1000,6 +1025,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1000 }1025 }
1001 ev.Msg("stats")1026 ev.Msg("stats")
1002 }1027 }
1028+ if len(requests) > 0 && (!enabled || o.off || o.wantOff) {
1029+ for _, q := range requests {
1030+ capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1031+ }
1032+ requests = nil
1033+ }
1003 if !enabled || o.pending {1034 if !enabled || o.pending {
1004 continue1035 continue
1005 }1036 }
@@ -1036,13 +1067,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1036 // shows is what the frame puts on screen: presentation feedback1067 // shows is what the frame puts on screen: presentation feedback
1037 // of windows it does not draw is discarded, not presented.1068 // of windows it does not draw is discarded, not presented.
1038 f := pendingFrame{shows: shownBy(scene, seen)}1069 f := pendingFrame{shows: shownBy(scene, seen)}
1039- fb, c := o.scanoutFrame(scene, surfaces)1070+ var fb uint32
1071+ var c ports.SurfaceContent
1072+ forceCompose := len(requests) > 0
1073+ if !forceCompose {
1074+ fb, c = o.scanoutFrame(scene, surfaces)
1075+ }
1040 direct := false1076 direct := false
1041 if fb != 0 {1077 if fb != 0 {
1042 direct, err = o.commitScanout(fb, c, pendingFrame{shows: map[ports.WindowID]uint64{c.ID: seen[c.ID]}})1078 direct, err = o.commitScanout(fb, c, pendingFrame{shows: map[ports.WindowID]uint64{c.ID: seen[c.ID]}})
1043 }1079 }
1044 if !direct {1080 if !direct {
1045- ov, composed := o.overlayFrame(scene, surfaces)1081+ var ov overlayWin
1082+ composed := scene
1083+ if !forceCompose {
1084+ ov, composed = o.overlayFrame(scene, surfaces)
1085+ }
1046 r.UseTarget(o.back)1086 r.UseTarget(o.back)
1047 done, rerr := r.Render(composed, surfaces)1087 done, rerr := r.Render(composed, surfaces)
1048 if rerr != nil {1088 if rerr != nil {
@@ -1060,6 +1100,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1060 return fmt.Errorf("render frame: %w", rerr)1100 return fmt.Errorf("render frame: %w", rerr)
1061 }1101 }
1062 }1102 }
1103+ if len(requests) > 0 {
1104+ // Scanout and the overlay plane were skipped for this frame.
1105+ o.log.Debug().Str("connector", o.conn.name).Int("captures", len(requests)).Msg("capture frame composed")
1106+ }
1107+ for _, q := range requests {
1108+ capture.Write(ctx, q, r, captured)
1109+ }
1110+ requests = nil
1063 // The overlay buffer is on screen like a scanned-out one: it1111 // The overlay buffer is on screen like a scanned-out one: it
1064 // is reported shown, so it is not released under the plane.1112 // is reported shown, so it is not released under the plane.
1065 f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true1113 f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true
M internal/adapters/drm/output_test.go +77 -1
@@ -2,6 +2,7 @@ package drm
22
33 import (
44 "context"
5+ "image"
56 "os"
67 "sync"
78 "sync/atomic"
@@ -398,7 +399,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
398399 ctx, cancel := context.WithCancel(context.Background())
399400 done := make(chan error, 1)
400401 go func() {
401- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, presented)
402+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, presented, nil, nil)
402403 }()
403404 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
404405 waitFor(t, func() bool { return frameCommits() == 1 })
@@ -447,3 +448,78 @@ func waitFor(t *testing.T, cond func() bool) {
447448 time.Sleep(2 * time.Millisecond)
448449 }
449450 }
451+
452+// A request must render even when the output already has an unchanged scene.
453+func TestCaptureForcesDRMComposition(t *testing.T) {
454+ o, k, commits, commitMu := testOutputMu(t)
455+ o.cursor, o.tearing = nil, false
456+ flips := make(chan flipEvent, 2)
457+ o.flipped = flips
458+ r := &portsmocks.MockRenderer{}
459+ makeBuf := func() ports.DMABuf {
460+ f, w, _ := os.Pipe()
461+ w.Close()
462+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
463+ }
464+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once()
465+ r.EXPECT().UseTarget(mock.Anything).Return()
466+ rendered := make(chan ports.Scene, 3)
467+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
468+ rendered <- s
469+ return nil, nil
470+ })
471+ r.EXPECT().Capture(image.Rect(0, 0, 2, 2), mock.MatchedBy(func(p []byte) bool { return len(p) >= 16 }), 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { copy(dst, []byte{3, 4, 5, 255}); return nil }).Once()
472+ r.EXPECT().Close().Return().Once()
473+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
474+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
475+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
476+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
477+ scenes := make(chan ports.Scene, 1)
478+ requests := make(chan ports.CaptureRequest, 1)
479+ replies := make(chan ports.CaptureDone, 1)
480+ ctx, cancel := context.WithCancel(context.Background())
481+ done := make(chan error, 1)
482+ go func() {
483+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, make(chan ports.OutputPresented, 8), requests, replies)
484+ }()
485+ scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Background: "#000000"}
486+ select {
487+ case <-rendered:
488+ case <-time.After(2 * time.Second):
489+ t.Fatal("no initial render")
490+ }
491+ waitFor(t, func() bool { commitMu.Lock(); defer commitMu.Unlock(); return len(*commits) > 0 })
492+ commitMu.Lock()
493+ last := (*commits)[len(*commits)-1]
494+ commitMu.Unlock()
495+ flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1}
496+ f, err := os.CreateTemp(t.TempDir(), "capture")
497+ if err != nil {
498+ t.Fatal(err)
499+ }
500+ if err := f.Truncate(16); err != nil {
501+ t.Fatal(err)
502+ }
503+ requests <- ports.CaptureRequest{ID: 4, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Dst: ports.SHMBuffer{File: f}}
504+ select {
505+ case result := <-replies:
506+ if result.Err != nil {
507+ t.Fatal(result.Err)
508+ }
509+ case <-time.After(2 * time.Second):
510+ t.Fatal("capture not replied")
511+ }
512+ select {
513+ case <-rendered:
514+ case <-time.After(2 * time.Second):
515+ t.Fatal("capture did not render")
516+ }
517+ r.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.
518+ if _, err := f.Stat(); err == nil {
519+ t.Fatal("fd not closed")
520+ }
521+ cancel()
522+ if err := <-done; err != nil {
523+ t.Fatal(err)
524+ }
525+}
@@ -2,6 +2,7 @@ package drm
2 2
3 import (3 import (
4 "context"4 "context"
5+ "image"
5 "os"6 "os"
6 "sync"7 "sync"
7 "sync/atomic"8 "sync/atomic"
@@ -398,7 +399,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
398 ctx, cancel := context.WithCancel(context.Background())399 ctx, cancel := context.WithCancel(context.Background())
399 done := make(chan error, 1)400 done := make(chan error, 1)
400 go func() {401 go func() {
401- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, presented)402+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, presented, nil, nil)
402 }()403 }()
403 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}404 scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
404 waitFor(t, func() bool { return frameCommits() == 1 })405 waitFor(t, func() bool { return frameCommits() == 1 })
@@ -447,3 +448,78 @@ func waitFor(t *testing.T, cond func() bool) {
447 time.Sleep(2 * time.Millisecond)448 time.Sleep(2 * time.Millisecond)
448 }449 }
449 }450 }
451+
452+// A request must render even when the output already has an unchanged scene.
453+func TestCaptureForcesDRMComposition(t *testing.T) {
454+ o, k, commits, commitMu := testOutputMu(t)
455+ o.cursor, o.tearing = nil, false
456+ flips := make(chan flipEvent, 2)
457+ o.flipped = flips
458+ r := &portsmocks.MockRenderer{}
459+ makeBuf := func() ports.DMABuf {
460+ f, w, _ := os.Pipe()
461+ w.Close()
462+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
463+ }
464+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once()
465+ r.EXPECT().UseTarget(mock.Anything).Return()
466+ rendered := make(chan ports.Scene, 3)
467+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
468+ rendered <- s
469+ return nil, nil
470+ })
471+ r.EXPECT().Capture(image.Rect(0, 0, 2, 2), mock.MatchedBy(func(p []byte) bool { return len(p) >= 16 }), 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { copy(dst, []byte{3, 4, 5, 255}); return nil }).Once()
472+ r.EXPECT().Close().Return().Once()
473+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
474+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
475+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
476+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
477+ scenes := make(chan ports.Scene, 1)
478+ requests := make(chan ports.CaptureRequest, 1)
479+ replies := make(chan ports.CaptureDone, 1)
480+ ctx, cancel := context.WithCancel(context.Background())
481+ done := make(chan error, 1)
482+ go func() {
483+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, make(chan ports.OutputPresented, 8), requests, replies)
484+ }()
485+ scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Background: "#000000"}
486+ select {
487+ case <-rendered:
488+ case <-time.After(2 * time.Second):
489+ t.Fatal("no initial render")
490+ }
491+ waitFor(t, func() bool { commitMu.Lock(); defer commitMu.Unlock(); return len(*commits) > 0 })
492+ commitMu.Lock()
493+ last := (*commits)[len(*commits)-1]
494+ commitMu.Unlock()
495+ flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1}
496+ f, err := os.CreateTemp(t.TempDir(), "capture")
497+ if err != nil {
498+ t.Fatal(err)
499+ }
500+ if err := f.Truncate(16); err != nil {
501+ t.Fatal(err)
502+ }
503+ requests <- ports.CaptureRequest{ID: 4, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Dst: ports.SHMBuffer{File: f}}
504+ select {
505+ case result := <-replies:
506+ if result.Err != nil {
507+ t.Fatal(result.Err)
508+ }
509+ case <-time.After(2 * time.Second):
510+ t.Fatal("capture not replied")
511+ }
512+ select {
513+ case <-rendered:
514+ case <-time.After(2 * time.Second):
515+ t.Fatal("capture did not render")
516+ }
517+ r.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.
518+ if _, err := f.Stat(); err == nil {
519+ t.Fatal("fd not closed")
520+ }
521+ cancel()
522+ if err := <-done; err != nil {
523+ t.Fatal(err)
524+ }
525+}
M internal/adapters/drm/power_test.go +1 -1
@@ -47,7 +47,7 @@ func TestRunOutputPower(t *testing.T) {
4747 ctx, cancel := context.WithCancel(context.Background())
4848 done := make(chan error, 1)
4949 go func() {
50- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, vt, scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8))
50+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, vt, scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8), nil, nil)
5151 }()
5252 on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
5353 scenes <- on
@@ -47,7 +47,7 @@ func TestRunOutputPower(t *testing.T) {
47 ctx, cancel := context.WithCancel(context.Background())47 ctx, cancel := context.WithCancel(context.Background())
48 done := make(chan error, 1)48 done := make(chan error, 1)
49 go func() {49 go func() {
50- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, vt, scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8))50+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, vt, scenes, make(chan ports.SurfaceContent), nil, make(chan ports.OutputPresented, 8), nil, nil)
51 }()51 }()
52 on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}52 on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
53 scenes <- on53 scenes <- on
M internal/adapters/drm/stuck_test.go +1 -1
@@ -69,7 +69,7 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
6969 ctx, cancel := context.WithCancel(context.Background())
7070 done := make(chan error, 1)
7171 go func() {
72- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents, nil, h.presented)
72+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents, nil, h.presented, nil, nil)
7373 }()
7474 h.count = func() int {
7575 commitMu.Lock()
@@ -69,7 +69,7 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
69 ctx, cancel := context.WithCancel(context.Background())69 ctx, cancel := context.WithCancel(context.Background())
70 done := make(chan error, 1)70 done := make(chan error, 1)
71 go func() {71 go func() {
72- done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents, nil, h.presented)72+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, h.active, h.scenes, h.contents, nil, h.presented, nil, nil)
73 }()73 }()
74 h.count = func() int {74 h.count = func() int {
75 commitMu.Lock()75 commitMu.Lock()
M internal/adapters/headless/output.go +39 -1
@@ -11,6 +11,7 @@ import (
1111 "path/filepath"
1212 "time"
1313
14+ "github.com/bnema/neferwl/internal/adapters/capture"
1415 "github.com/bnema/neferwl/internal/adapters/syncfile"
1516 "github.com/bnema/neferwl/internal/ports"
1617 "github.com/bnema/zerowrap"
@@ -29,9 +30,10 @@ type Options struct {
2930 // frame, so wayland can release client buffers (nil: no reports).
3031 Name string
3132 Presented chan<- ports.OutputPresented
33+ Captured chan<- ports.CaptureDone
3234 }
3335
34-func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
36+func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error {
3537 r, err := opts.NewRenderer(opts.Width, opts.Height)
3638 if err != nil {
3739 return fmt.Errorf("create renderer: %w", err)
@@ -41,6 +43,22 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
4143 var scene ports.Scene
4244 haveScene, dirty := false, false
4345 frame := 0
46+ var requests []ports.CaptureRequest
47+ ctx, cancelCaptures := context.WithCancel(ctx)
48+ defer func() {
49+ cancelCaptures()
50+ for _, q := range requests {
51+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
52+ }
53+ for {
54+ select {
55+ case q := <-incoming:
56+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
57+ default:
58+ return
59+ }
60+ }
61+ }()
4462 var want ports.CursorChange
4563 cursorScale := -1.0 // not loaded yet
4664 seen := map[ports.WindowID]uint64{}
@@ -66,6 +84,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
6684 select {
6785 case <-ctx.Done():
6886 return nil
87+ case q := <-incoming:
88+ if scene.Off {
89+ capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
90+ } else {
91+ requests = append(requests, q)
92+ dirty = haveScene
93+ }
6994 case <-retry:
7095 pending = opts.send(pending)
7196 continue
@@ -105,6 +130,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
105130 break drain
106131 }
107132 }
133+ if scene.Off && len(requests) > 0 {
134+ for _, q := range requests {
135+ capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
136+ }
137+ requests = nil
138+ }
108139 if !haveScene || !dirty || scene.Off {
109140 // Contents not drawn are still read: report them. An output
110141 // turned off draws nothing.
@@ -137,6 +168,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
137168 return fmt.Errorf("frame fence: %w", err)
138169 }
139170 }
171+ if len(requests) > 0 {
172+ opts.Log.Debug().Str("output", opts.Name).Int("captures", len(requests)).Msg("capture frame composed")
173+ }
174+ for _, q := range requests {
175+ capture.Write(ctx, q, r, opts.Captured)
176+ }
177+ requests = nil
140178 frame++
141179 pending = opts.flipped(pending, seen, scene, surfaces)
142180 if opts.ScreenshotDir != "" {
@@ -11,6 +11,7 @@ import (
11 "path/filepath"11 "path/filepath"
12 "time"12 "time"
13 13
14+ "github.com/bnema/neferwl/internal/adapters/capture"
14 "github.com/bnema/neferwl/internal/adapters/syncfile"15 "github.com/bnema/neferwl/internal/adapters/syncfile"
15 "github.com/bnema/neferwl/internal/ports"16 "github.com/bnema/neferwl/internal/ports"
16 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
@@ -29,9 +30,10 @@ type Options struct {
29 // frame, so wayland can release client buffers (nil: no reports).30 // frame, so wayland can release client buffers (nil: no reports).
30 Name string31 Name string
31 Presented chan<- ports.OutputPresented32 Presented chan<- ports.OutputPresented
33+ Captured chan<- ports.CaptureDone
32 }34 }
33 35
34-func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {36+func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error {
35 r, err := opts.NewRenderer(opts.Width, opts.Height)37 r, err := opts.NewRenderer(opts.Width, opts.Height)
36 if err != nil {38 if err != nil {
37 return fmt.Errorf("create renderer: %w", err)39 return fmt.Errorf("create renderer: %w", err)
@@ -41,6 +43,22 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
41 var scene ports.Scene43 var scene ports.Scene
42 haveScene, dirty := false, false44 haveScene, dirty := false, false
43 frame := 045 frame := 0
46+ var requests []ports.CaptureRequest
47+ ctx, cancelCaptures := context.WithCancel(ctx)
48+ defer func() {
49+ cancelCaptures()
50+ for _, q := range requests {
51+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
52+ }
53+ for {
54+ select {
55+ case q := <-incoming:
56+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
57+ default:
58+ return
59+ }
60+ }
61+ }()
44 var want ports.CursorChange62 var want ports.CursorChange
45 cursorScale := -1.0 // not loaded yet63 cursorScale := -1.0 // not loaded yet
46 seen := map[ports.WindowID]uint64{}64 seen := map[ports.WindowID]uint64{}
@@ -66,6 +84,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
66 select {84 select {
67 case <-ctx.Done():85 case <-ctx.Done():
68 return nil86 return nil
87+ case q := <-incoming:
88+ if scene.Off {
89+ capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
90+ } else {
91+ requests = append(requests, q)
92+ dirty = haveScene
93+ }
69 case <-retry:94 case <-retry:
70 pending = opts.send(pending)95 pending = opts.send(pending)
71 continue96 continue
@@ -105,6 +130,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
105 break drain130 break drain
106 }131 }
107 }132 }
133+ if scene.Off && len(requests) > 0 {
134+ for _, q := range requests {
135+ capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
136+ }
137+ requests = nil
138+ }
108 if !haveScene || !dirty || scene.Off {139 if !haveScene || !dirty || scene.Off {
109 // Contents not drawn are still read: report them. An output140 // Contents not drawn are still read: report them. An output
110 // turned off draws nothing.141 // turned off draws nothing.
@@ -137,6 +168,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
137 return fmt.Errorf("frame fence: %w", err)168 return fmt.Errorf("frame fence: %w", err)
138 }169 }
139 }170 }
171+ if len(requests) > 0 {
172+ opts.Log.Debug().Str("output", opts.Name).Int("captures", len(requests)).Msg("capture frame composed")
173+ }
174+ for _, q := range requests {
175+ capture.Write(ctx, q, r, opts.Captured)
176+ }
177+ requests = nil
140 frame++178 frame++
141 pending = opts.flipped(pending, seen, scene, surfaces)179 pending = opts.flipped(pending, seen, scene, surfaces)
142 if opts.ScreenshotDir != "" {180 if opts.ScreenshotDir != "" {
M internal/adapters/headless/output_test.go +48 -5
@@ -76,7 +76,7 @@ func TestRun(t *testing.T) {
7676 ctx, cancel := context.WithCancel(context.Background())
7777 done := make(chan error, 1)
7878 go func() {
79- done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
79+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
8080 }()
8181 waitFrames(t, f, 1)
8282 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
@@ -115,7 +115,7 @@ func TestRunSkipsContentNotShown(t *testing.T) {
115115 ctx, cancel := context.WithCancel(context.Background())
116116 done := make(chan error, 1)
117117 go func() {
118- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
118+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
119119 }()
120120 waitFrames(t, f, 1)
121121 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
@@ -139,11 +139,11 @@ func TestErrors(t *testing.T) {
139139 expected := errors.New("failure")
140140 scenes := make(chan ports.Scene, 1)
141141 scenes <- ports.Scene{}
142- if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil); !errors.Is(err, expected) {
142+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil, nil); !errors.Is(err, expected) {
143143 t.Fatal(err)
144144 }
145145 r, _ := recordingRenderer(t, expected)
146- if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil); !errors.Is(err, expected) {
146+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil); !errors.Is(err, expected) {
147147 t.Fatal(err)
148148 }
149149 }
@@ -184,7 +184,7 @@ func TestRunPassesLayerContent(t *testing.T) {
184184 ctx, cancel := context.WithCancel(context.Background())
185185 done := make(chan error, 1)
186186 go func() {
187- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
187+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
188188 }()
189189 deadline := time.After(3 * time.Second)
190190 for {
@@ -205,3 +205,46 @@ func TestRunPassesLayerContent(t *testing.T) {
205205 t.Fatal(err)
206206 }
207207 }
208+
209+func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
210+ scenes := make(chan ports.Scene, 1)
211+ requests := make(chan ports.CaptureRequest, 1)
212+ replies := make(chan ports.CaptureDone, 1)
213+ r, frames := recordingRenderer(t, nil)
214+
215+ r.EXPECT().Capture(image.Rect(0, 0, 2, 2), mock.MatchedBy(func(p []byte) bool { return len(p) >= 16 }), 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { copy(dst, []byte{1, 2, 3, 255}); return nil }).Once()
216+ ctx, cancel := context.WithCancel(context.Background())
217+ done := make(chan error, 1)
218+ go func() {
219+ done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, requests)
220+ }()
221+ scenes <- ports.Scene{Background: "#000000"}
222+ waitFrames(t, frames, 1)
223+ f, err := os.CreateTemp(t.TempDir(), "shot")
224+ if err != nil {
225+ t.Fatal(err)
226+ }
227+ if err := f.Truncate(16); err != nil {
228+ t.Fatal(err)
229+ }
230+ requests <- ports.CaptureRequest{ID: 1, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Dst: ports.SHMBuffer{File: f}}
231+ select {
232+ case result := <-replies:
233+ if result.Err != nil {
234+ t.Fatal(result.Err)
235+ }
236+ case <-time.After(3 * time.Second):
237+ t.Fatal("no capture")
238+ }
239+ r.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.
240+ if _, err := f.Stat(); err == nil {
241+ t.Fatal("descriptor not closed")
242+ }
243+ if s, _ := frames.snapshot(); len(s) < 2 {
244+ t.Fatal("no fresh frame")
245+ }
246+ cancel()
247+ if err := <-done; err != nil {
248+ t.Fatal(err)
249+ }
250+}
@@ -76,7 +76,7 @@ func TestRun(t *testing.T) {
76 ctx, cancel := context.WithCancel(context.Background())76 ctx, cancel := context.WithCancel(context.Background())
77 done := make(chan error, 1)77 done := make(chan error, 1)
78 go func() {78 go func() {
79- done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)79+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
80 }()80 }()
81 waitFrames(t, f, 1)81 waitFrames(t, f, 1)
82 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {82 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
@@ -115,7 +115,7 @@ func TestRunSkipsContentNotShown(t *testing.T) {
115 ctx, cancel := context.WithCancel(context.Background())115 ctx, cancel := context.WithCancel(context.Background())
116 done := make(chan error, 1)116 done := make(chan error, 1)
117 go func() {117 go func() {
118- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)118+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
119 }()119 }()
120 waitFrames(t, f, 1)120 waitFrames(t, f, 1)
121 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}121 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
@@ -139,11 +139,11 @@ func TestErrors(t *testing.T) {
139 expected := errors.New("failure")139 expected := errors.New("failure")
140 scenes := make(chan ports.Scene, 1)140 scenes := make(chan ports.Scene, 1)
141 scenes <- ports.Scene{}141 scenes <- ports.Scene{}
142- if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil); !errors.Is(err, expected) {142+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil, nil); !errors.Is(err, expected) {
143 t.Fatal(err)143 t.Fatal(err)
144 }144 }
145 r, _ := recordingRenderer(t, expected)145 r, _ := recordingRenderer(t, expected)
146- if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil); !errors.Is(err, expected) {146+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil); !errors.Is(err, expected) {
147 t.Fatal(err)147 t.Fatal(err)
148 }148 }
149 }149 }
@@ -184,7 +184,7 @@ func TestRunPassesLayerContent(t *testing.T) {
184 ctx, cancel := context.WithCancel(context.Background())184 ctx, cancel := context.WithCancel(context.Background())
185 done := make(chan error, 1)185 done := make(chan error, 1)
186 go func() {186 go func() {
187- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)187+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
188 }()188 }()
189 deadline := time.After(3 * time.Second)189 deadline := time.After(3 * time.Second)
190 for {190 for {
@@ -205,3 +205,46 @@ func TestRunPassesLayerContent(t *testing.T) {
205 t.Fatal(err)205 t.Fatal(err)
206 }206 }
207 }207 }
208+
209+func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
210+ scenes := make(chan ports.Scene, 1)
211+ requests := make(chan ports.CaptureRequest, 1)
212+ replies := make(chan ports.CaptureDone, 1)
213+ r, frames := recordingRenderer(t, nil)
214+
215+ r.EXPECT().Capture(image.Rect(0, 0, 2, 2), mock.MatchedBy(func(p []byte) bool { return len(p) >= 16 }), 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { copy(dst, []byte{1, 2, 3, 255}); return nil }).Once()
216+ ctx, cancel := context.WithCancel(context.Background())
217+ done := make(chan error, 1)
218+ go func() {
219+ done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, requests)
220+ }()
221+ scenes <- ports.Scene{Background: "#000000"}
222+ waitFrames(t, frames, 1)
223+ f, err := os.CreateTemp(t.TempDir(), "shot")
224+ if err != nil {
225+ t.Fatal(err)
226+ }
227+ if err := f.Truncate(16); err != nil {
228+ t.Fatal(err)
229+ }
230+ requests <- ports.CaptureRequest{ID: 1, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Dst: ports.SHMBuffer{File: f}}
231+ select {
232+ case result := <-replies:
233+ if result.Err != nil {
234+ t.Fatal(result.Err)
235+ }
236+ case <-time.After(3 * time.Second):
237+ t.Fatal("no capture")
238+ }
239+ r.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.
240+ if _, err := f.Stat(); err == nil {
241+ t.Fatal("descriptor not closed")
242+ }
243+ if s, _ := frames.snapshot(); len(s) < 2 {
244+ t.Fatal("no fresh frame")
245+ }
246+ cancel()
247+ if err := <-done; err != nil {
248+ t.Fatal(err)
249+ }
250+}
A internal/adapters/vulkan/capture_test.go +31 -0
@@ -0,0 +1,31 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestCaptureLastFrameRegion(t *testing.T) {
11+ r, err := New(8, 8)
12+ if err != nil {
13+ t.Skipf("Vulkan unavailable: %v", err)
14+ }
15+ defer r.Close()
16+ if err := render(r, ports.Scene{Background: "#123456"}, nil); err != nil {
17+ t.Fatal(err)
18+ }
19+ dst := make([]byte, 24)
20+ if err := r.Capture(image.Rect(2, 3, 4, 5), dst, 12); err != nil {
21+ t.Fatal(err)
22+ }
23+ for _, at := range []int{0, 4, 12, 16} {
24+ if got := dst[at : at+4]; got[0] != 0x56 || got[1] != 0x34 || got[2] != 0x12 || got[3] != 255 {
25+ t.Fatalf("pixel %d: %x", at, got)
26+ }
27+ }
28+ if err := r.Capture(image.Rect(-1, 0, 1, 1), dst, 12); err == nil {
29+ t.Fatal("invalid region accepted")
30+ }
31+}
@@ -0,0 +1,31 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestCaptureLastFrameRegion(t *testing.T) {
11+ r, err := New(8, 8)
12+ if err != nil {
13+ t.Skipf("Vulkan unavailable: %v", err)
14+ }
15+ defer r.Close()
16+ if err := render(r, ports.Scene{Background: "#123456"}, nil); err != nil {
17+ t.Fatal(err)
18+ }
19+ dst := make([]byte, 24)
20+ if err := r.Capture(image.Rect(2, 3, 4, 5), dst, 12); err != nil {
21+ t.Fatal(err)
22+ }
23+ for _, at := range []int{0, 4, 12, 16} {
24+ if got := dst[at : at+4]; got[0] != 0x56 || got[1] != 0x34 || got[2] != 0x12 || got[3] != 255 {
25+ t.Fatalf("pixel %d: %x", at, got)
26+ }
27+ }
28+ if err := r.Capture(image.Rect(-1, 0, 1, 1), dst, 12); err == nil {
29+ t.Fatal("invalid region accepted")
30+ }
31+}
M internal/adapters/vulkan/renderer.go +25 -0
@@ -274,6 +274,31 @@ func (r *Renderer) Pixels() *image.RGBA {
274274 return out
275275 }
276276
277+// Capture reads the last rendered image without changing the renderer's target.
278+func (r *Renderer) Capture(region image.Rectangle, dst []byte, stride int) error {
279+ bounds := image.Rect(0, 0, r.width, r.height)
280+ w, h := region.Dx(), region.Dy()
281+ if region.Empty() || !region.In(bounds) || stride < w*4 || len(dst) < (h-1)*stride+w*4 {
282+ return fmt.Errorf("invalid capture region or destination")
283+ }
284+ if r.mapped == nil || r.last == nil || r.last.image == 0 {
285+ return fmt.Errorf("no rendered frame")
286+ }
287+ if err := r.readback(); err != nil {
288+ return err
289+ }
290+ src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*4)
291+ for y := range h {
292+ start := ((region.Min.Y+y)*r.width + region.Min.X) * 4
293+ row := dst[y*stride : y*stride+w*4]
294+ copy(row, src[start:start+w*4])
295+ for x := 3; x < len(row); x += 4 {
296+ row[x] = 255
297+ }
298+ }
299+ return nil
300+}
301+
277302 // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE.
278303 const (
279304 queueFamilyIgnored = ^uint32(0)
@@ -274,6 +274,31 @@ func (r *Renderer) Pixels() *image.RGBA {
274 return out274 return out
275 }275 }
276 276
277+// Capture reads the last rendered image without changing the renderer's target.
278+func (r *Renderer) Capture(region image.Rectangle, dst []byte, stride int) error {
279+ bounds := image.Rect(0, 0, r.width, r.height)
280+ w, h := region.Dx(), region.Dy()
281+ if region.Empty() || !region.In(bounds) || stride < w*4 || len(dst) < (h-1)*stride+w*4 {
282+ return fmt.Errorf("invalid capture region or destination")
283+ }
284+ if r.mapped == nil || r.last == nil || r.last.image == 0 {
285+ return fmt.Errorf("no rendered frame")
286+ }
287+ if err := r.readback(); err != nil {
288+ return err
289+ }
290+ src := unsafe.Slice((*byte)(r.mapped), r.width*r.height*4)
291+ for y := range h {
292+ start := ((region.Min.Y+y)*r.width + region.Min.X) * 4
293+ row := dst[y*stride : y*stride+w*4]
294+ copy(row, src[start:start+w*4])
295+ for x := 3; x < len(row); x += 4 {
296+ row[x] = 255
297+ }
298+ }
299+ return nil
300+}
301+
277 // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE.302 // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE.
278 const (303 const (
279 queueFamilyIgnored = ^uint32(0)304 queueFamilyIgnored = ^uint32(0)
M internal/adapters/wayland/globals.go +1 -0
@@ -23,6 +23,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
2323 func() error { return registerSyncobj(d, s) },
2424 func() error { return registerInhibit(d, s) },
2525 func() error { return registerIdle(d, s) },
26+ func() error { return registerCapture(d, s) },
2627 func() error { return registerActivation(d, s) },
2728 func() error { return registerVirtualKeyboard(d, s) },
2829 func() error {
@@ -23,6 +23,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
23 func() error { return registerSyncobj(d, s) },23 func() error { return registerSyncobj(d, s) },
24 func() error { return registerInhibit(d, s) },24 func() error { return registerInhibit(d, s) },
25 func() error { return registerIdle(d, s) },25 func() error { return registerIdle(d, s) },
26+ func() error { return registerCapture(d, s) },
26 func() error { return registerActivation(d, s) },27 func() error { return registerActivation(d, s) },
27 func() error { return registerVirtualKeyboard(d, s) },28 func() error { return registerVirtualKeyboard(d, s) },
28 func() error {29 func() error {
M internal/adapters/wayland/outputs.go +14 -0
@@ -147,6 +147,12 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
147147 continue
148148 }
149149 o.global.Remove()
150+ for session := range s.captureSessions {
151+ if session.o == o && !session.stopped {
152+ session.stopped = true
153+ session.res.SendStopped()
154+ }
155+ }
150156 s.log.Info().Str("output", o.name()).Msg("output removed")
151157 for _, l := range s.layers {
152158 if l.output == o {
@@ -168,7 +174,15 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
168174 continue
169175 }
170176 scaleChanged := o.place.Scale != p.Scale
177+ sizeChanged := o.place.Info.Width != p.Info.Width || o.place.Info.Height != p.Info.Height
171178 o.place = p
179+ if sizeChanged {
180+ for session := range s.captureSessions {
181+ if session.o == o && !session.stopped {
182+ session.sendConstraints(p.Info.Width, p.Info.Height)
183+ }
184+ }
185+ }
172186 for _, r := range o.resources {
173187 o.sendAll(r)
174188 }
@@ -147,6 +147,12 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
147 continue147 continue
148 }148 }
149 o.global.Remove()149 o.global.Remove()
150+ for session := range s.captureSessions {
151+ if session.o == o && !session.stopped {
152+ session.stopped = true
153+ session.res.SendStopped()
154+ }
155+ }
150 s.log.Info().Str("output", o.name()).Msg("output removed")156 s.log.Info().Str("output", o.name()).Msg("output removed")
151 for _, l := range s.layers {157 for _, l := range s.layers {
152 if l.output == o {158 if l.output == o {
@@ -168,7 +174,15 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
168 continue174 continue
169 }175 }
170 scaleChanged := o.place.Scale != p.Scale176 scaleChanged := o.place.Scale != p.Scale
177+ sizeChanged := o.place.Info.Width != p.Info.Width || o.place.Info.Height != p.Info.Height
171 o.place = p178 o.place = p
179+ if sizeChanged {
180+ for session := range s.captureSessions {
181+ if session.o == o && !session.stopped {
182+ session.sendConstraints(p.Info.Width, p.Info.Height)
183+ }
184+ }
185+ }
172 for _, r := range o.resources {186 for _, r := range o.resources {
173 o.sendAll(r)187 o.sendAll(r)
174 }188 }
A internal/adapters/wayland/screencopy.go +387 -0
@@ -0,0 +1,387 @@
1+package wayland
2+
3+import (
4+ "image"
5+ "math"
6+ "os"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
11+ ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
12+ "github.com/bnema/purego-libwayland/protocol/wayland"
13+ wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
14+ "github.com/bnema/purego-libwayland/server"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// The renderer's scene and wl_output modes are already in physical orientation;
19+// this compositor does not advertise rotated wl_output transforms.
20+func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
21+ if o == nil {
22+ return image.Rectangle{}
23+ }
24+ scale := o.place.Scale
25+ if scale <= 0 {
26+ scale = 1
27+ }
28+ p := image.Rect(int(math.Floor(float64(logical.Min.X)*scale)), int(math.Floor(float64(logical.Min.Y)*scale)), int(math.Ceil(float64(logical.Max.X)*scale)), int(math.Ceil(float64(logical.Max.Y)*scale)))
29+ return p.Intersect(image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height))
30+}
31+func captureTime(t time.Time) (uint32, uint32, uint32) {
32+ sec := uint64(t.Unix())
33+ return uint32(sec >> 32), uint32(sec), uint32(t.Nanosecond())
34+}
35+func registerCapture(d *server.Display, s *Server) error {
36+ if err := wlr.NewZwlrScreencopyManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
37+ _, _ = wlr.NewZwlrScreencopyManagerV1(c, int32(v), id, screencopyManager{s})
38+ }); err != nil {
39+ return err
40+ }
41+ if err := source.NewExtOutputImageCaptureSourceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
42+ _, _ = source.NewExtOutputImageCaptureSourceManagerV1(c, int32(v), id, outputCaptureSource{s})
43+ }); err != nil {
44+ return err
45+ }
46+ return ext.NewExtImageCopyCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
47+ _, _ = ext.NewExtImageCopyCaptureManagerV1(c, int32(v), id, copyCaptureManager{s})
48+ })
49+}
50+
51+type screencopyManager struct{ s *Server }
52+
53+func (screencopyManager) Destroy(*wlr.ZwlrScreencopyManagerV1) {}
54+func (m screencopyManager) CaptureOutput(r *wlr.ZwlrScreencopyManagerV1, id uint32, cursor int32, out *wayland.Output) {
55+ o := m.s.outputOf(out)
56+ if o == nil {
57+ m.create(r, id, cursor, nil, image.Rectangle{})
58+ return
59+ }
60+ m.create(r, id, cursor, o, image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height))
61+}
62+func (m screencopyManager) CaptureOutputRegion(r *wlr.ZwlrScreencopyManagerV1, id uint32, cursor int32, out *wayland.Output, x, y, w, h int32) {
63+ o := m.s.outputOf(out)
64+ if w <= 0 || h <= 0 {
65+ m.create(r, id, cursor, o, image.Rectangle{})
66+ return
67+ }
68+ m.create(r, id, cursor, o, captureRegion(o, image.Rect(int(x), int(y), int64Clamp(int64(x)+int64(w)), int64Clamp(int64(y)+int64(h)))))
69+}
70+func int64Clamp(x int64) int {
71+ if x > int64(^uint(0)>>1) {
72+ return int(^uint(0) >> 1)
73+ }
74+ return int(x)
75+}
76+func (m screencopyManager) create(r *wlr.ZwlrScreencopyManagerV1, id uint32, cursor int32, o *output, rect image.Rectangle) {
77+ state := &screencopyFrame{s: m.s, o: o, region: rect, cursor: cursor != 0}
78+ f, err := wlr.NewZwlrScreencopyFrameV1(r.Client(), r.Version(), id, state)
79+ if err != nil {
80+ return
81+ }
82+ state.res = f
83+ if rect.Empty() {
84+ f.SendFailed()
85+ state.used = true
86+ return
87+ }
88+ f.SendBuffer(uint32(wayland.ShmFormatXrgb8888), uint32(rect.Dx()), uint32(rect.Dy()), uint32(rect.Dx()*4))
89+ if f.Version() >= 3 {
90+ f.SendBufferDone()
91+ }
92+}
93+
94+type screencopyFrame struct {
95+ s *Server
96+ res *wlr.ZwlrScreencopyFrameV1
97+ o *output
98+ region image.Rectangle
99+ cursor, used bool
100+ replyID uint64
101+}
102+
103+func (f *screencopyFrame) Destroy(*wlr.ZwlrScreencopyFrameV1) { delete(f.s.captureReplies, f.replyID) }
104+func (f *screencopyFrame) Copy(_ *wlr.ZwlrScreencopyFrameV1, b *wayland.Buffer) { f.copy(b, false) }
105+func (f *screencopyFrame) CopyWithDamage(_ *wlr.ZwlrScreencopyFrameV1, b *wayland.Buffer) {
106+ f.copy(b, true)
107+}
108+func (f *screencopyFrame) copy(b *wayland.Buffer, damage bool) {
109+ if f.used {
110+ f.res.PostError(uint32(wlr.ZwlrScreencopyFrameV1ErrorAlreadyUsed), "frame already used")
111+ return
112+ }
113+ f.used = true
114+ if f.o == nil || f.region.Empty() || f.s.outputByNameExact(f.o.name()) != f.o {
115+ f.res.SendFailed()
116+ return
117+ }
118+ if !f.s.validCaptureBuffer(b, f.region, uint32(wayland.ShmFormatXrgb8888)) {
119+ f.res.PostError(uint32(wlr.ZwlrScreencopyFrameV1ErrorInvalidBuffer), "invalid shm buffer")
120+ return
121+ }
122+ id, ok := f.s.requestCapture(f.o, f.region, f.cursor, b, uint32(wayland.ShmFormatXrgb8888), func(done ports.CaptureDone) {
123+ if done.Err != nil {
124+ f.res.SendFailed()
125+ return
126+ }
127+ f.res.SendFlags(0)
128+ if damage && f.res.Version() >= 2 {
129+ f.res.SendDamage(0, 0, uint32(f.region.Dx()), uint32(f.region.Dy()))
130+ }
131+ hi, lo, ns := captureTime(done.Time)
132+ f.res.SendReady(hi, lo, ns)
133+ }, f.res.Resource)
134+ f.replyID = id
135+ if !ok {
136+ f.res.SendFailed()
137+ }
138+}
139+
140+// validCaptureBuffer checks the advertised shm constraints before any request is queued.
141+func (s *Server) validCaptureBuffer(b *wayland.Buffer, rect image.Rectangle, format uint32) bool {
142+ if b == nil {
143+ return false
144+ }
145+ buf, ok := s.buffers[b.Resource].(*buffer)
146+ return ok && (format == uint32(wayland.ShmFormatArgb8888) || format == uint32(wayland.ShmFormatXrgb8888)) && buf.width == rect.Dx() && buf.height == rect.Dy() && buf.stride >= rect.Dx()*4 && buf.format == format && fileHolds(buf.pool.file, int64(buf.offset)+int64(buf.height-1)*int64(buf.stride)+int64(buf.width)*4)
147+}
148+
149+// requestCapture duplicates the pool descriptor, so destroying the wl_buffer
150+// cannot invalidate a capture in flight.
151+func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b *wayland.Buffer, format uint32, reply func(ports.CaptureDone), life *server.Resource) (uint64, bool) {
152+ if o == nil || b == nil || s.channels.Captures == nil || s.channels.Captured == nil || s.outputByNameExact(o.name()) != o {
153+ return 0, false
154+ }
155+ if !s.validCaptureBuffer(b, rect, format) {
156+ s.log.Debug().Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Msg("capture buffer rejected")
157+ return 0, false
158+ }
159+ buf := s.buffers[b.Resource].(*buffer)
160+ fd, err := unix.Dup(int(buf.pool.file.Fd()))
161+ if err != nil {
162+ s.log.Warn().Err(err).Str("output", o.name()).Msg("capture: dup shm fd")
163+ return 0, false
164+ }
165+ s.nextCapture++
166+ id := s.nextCapture
167+ start := time.Now()
168+ s.captureReplies[id] = func(done ports.CaptureDone) {
169+ ev := s.log.Debug()
170+ if done.Err != nil {
171+ ev = s.log.Info().Err(done.Err)
172+ }
173+ ev.Uint64("id", id).Str("output", done.Output).Dur("took", time.Since(start)).Bool("delivered", life.Alive()).Msg("capture done")
174+ if life.Alive() {
175+ reply(done)
176+ }
177+ }
178+ 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}
179+ select {
180+ case s.channels.Captures <- req:
181+ s.log.Debug().Uint64("id", id).Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Bool("cursor", cursor).Msg("capture requested")
182+ return id, true
183+ default:
184+ delete(s.captureReplies, id)
185+ req.Dst.File.Close()
186+ s.log.Warn().Str("output", o.name()).Msg("capture refused: request queue full")
187+ return 0, false
188+ }
189+}
190+func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
191+ for {
192+ select {
193+ case <-ctxDone:
194+ return
195+ case <-s.display.Stopped():
196+ return
197+ case done, ok := <-s.channels.Captured:
198+ if !ok {
199+ return
200+ }
201+ if !s.display.Do(func() {
202+ if fn := s.captureReplies[done.ID]; fn != nil {
203+ delete(s.captureReplies, done.ID)
204+ fn(done)
205+ }
206+ }) {
207+ return
208+ }
209+ }
210+ }
211+}
212+
213+type outputCaptureSource struct{ s *Server }
214+
215+func (outputCaptureSource) Destroy(*source.ExtOutputImageCaptureSourceManagerV1) {}
216+func (m outputCaptureSource) CreateSource(r *source.ExtOutputImageCaptureSourceManagerV1, id uint32, out *wayland.Output) {
217+ src, err := source.NewExtImageCaptureSourceV1(r.Client(), 1, id, captureSourceHandler{})
218+ if err != nil {
219+ return
220+ }
221+ m.s.captureSources[src.Resource] = m.s.outputOf(out)
222+ src.OnDestroy = func() { delete(m.s.captureSources, src.Resource) }
223+}
224+
225+type captureSourceHandler struct{}
226+
227+func (captureSourceHandler) Destroy(*source.ExtImageCaptureSourceV1) {}
228+
229+type copyCaptureManager struct{ s *Server }
230+
231+func (copyCaptureManager) Destroy(*ext.ExtImageCopyCaptureManagerV1) {}
232+func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, id uint32, src *source.ExtImageCaptureSourceV1, options uint32) {
233+ var o *output
234+ if options&^1 != 0 {
235+ r.PostError(uint32(ext.ExtImageCopyCaptureManagerV1ErrorInvalidOption), "invalid capture option")
236+ return
237+ }
238+ if src != nil {
239+ o = m.s.captureSources[src.Resource]
240+ }
241+ state := &captureSession{s: m.s, o: o, cursor: options&1 != 0}
242+ res, err := ext.NewExtImageCopyCaptureSessionV1(r.Client(), 1, id, state)
243+ if err != nil {
244+ return
245+ }
246+ state.res = res
247+ m.s.captureSessions[state] = struct{}{}
248+ // Frames outlive their session (the spec): each frame drops its own reply.
249+ res.OnDestroy = func() { delete(m.s.captureSessions, state) }
250+ if o == nil {
251+ res.SendStopped()
252+ state.stopped = true
253+ return
254+ }
255+ state.sendConstraints(o.place.Info.Width, o.place.Info.Height)
256+}
257+
258+// sendConstraints sends one complete constraint batch: formats, size, done.
259+func (c *captureSession) sendConstraints(w, h int) {
260+ c.res.SendBufferSize(uint32(w), uint32(h))
261+ c.res.SendShmFormat(uint32(wayland.ShmFormatXrgb8888))
262+ c.res.SendShmFormat(uint32(wayland.ShmFormatArgb8888))
263+ c.res.SendDone()
264+}
265+func (m copyCaptureManager) CreatePointerCursorSession(r *ext.ExtImageCopyCaptureManagerV1, id uint32, _ *source.ExtImageCaptureSourceV1, _ *wayland.Pointer) {
266+ state := &cursorCaptureSession{}
267+ res, err := ext.NewExtImageCopyCaptureCursorSessionV1(r.Client(), 1, id, state)
268+ if err == nil {
269+ state.res = res
270+ }
271+}
272+
273+type cursorCaptureSession struct {
274+ res *ext.ExtImageCopyCaptureCursorSessionV1
275+ used bool
276+}
277+
278+func (*cursorCaptureSession) Destroy(*ext.ExtImageCopyCaptureCursorSessionV1) {}
279+func (c *cursorCaptureSession) GetCaptureSession(_ *ext.ExtImageCopyCaptureCursorSessionV1, id uint32) {
280+ if c.used {
281+ c.res.PostError(uint32(ext.ExtImageCopyCaptureCursorSessionV1ErrorDuplicateSession), "session already created")
282+ return
283+ }
284+ c.used = true
285+ state := &captureSession{stopped: true}
286+ res, err := ext.NewExtImageCopyCaptureSessionV1(c.res.Client(), 1, id, state)
287+ if err == nil {
288+ state.res = res
289+ res.SendStopped()
290+ }
291+}
292+
293+type captureSession struct {
294+ s *Server
295+ o *output
296+ res *ext.ExtImageCopyCaptureSessionV1
297+ cursor, stopped bool
298+ frame *captureExtFrame
299+}
300+
301+func (*captureSession) Destroy(*ext.ExtImageCopyCaptureSessionV1) {}
302+func (c *captureSession) CreateFrame(r *ext.ExtImageCopyCaptureSessionV1, id uint32) {
303+ if c.frame != nil {
304+ r.PostError(uint32(ext.ExtImageCopyCaptureSessionV1ErrorDuplicateFrame), "frame still alive")
305+ return
306+ }
307+ state := &captureExtFrame{session: c}
308+ res, err := ext.NewExtImageCopyCaptureFrameV1(r.Client(), 1, id, state)
309+ if err == nil {
310+ state.res = res
311+ c.frame = state
312+ res.OnDestroy = func() {
313+ if c.frame == state {
314+ c.frame = nil
315+ }
316+ if c.s != nil {
317+ delete(c.s.captureReplies, state.replyID)
318+ }
319+ }
320+ }
321+}
322+
323+type captureExtFrame struct {
324+ session *captureSession
325+ res *ext.ExtImageCopyCaptureFrameV1
326+ buf *wayland.Buffer
327+ used bool
328+ replyID uint64
329+}
330+
331+func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}
332+func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {
333+ if f.used {
334+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
335+ return
336+ }
337+ f.buf = b
338+}
339+func (f *captureExtFrame) DamageBuffer(_ *ext.ExtImageCopyCaptureFrameV1, x, y, w, h int32) {
340+ if f.used {
341+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
342+ return
343+ }
344+ if x < 0 || y < 0 || w <= 0 || h <= 0 {
345+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorInvalidBufferDamage), "invalid damage")
346+ }
347+}
348+func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
349+ if f.used {
350+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
351+ return
352+ }
353+ f.used = true
354+ c := f.session
355+ if c.stopped || c.o == nil || c.s.outputByNameExact(c.o.name()) != c.o {
356+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
357+ return
358+ }
359+ region := image.Rect(0, 0, c.o.place.Info.Width, c.o.place.Info.Height)
360+ if f.buf == nil {
361+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")
362+ return
363+ }
364+ buf, ok := c.s.buffers[f.buf.GetResource()].(*buffer)
365+ if !ok {
366+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints))
367+ return
368+ }
369+ f.replyID, ok = c.s.requestCapture(c.o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {
370+ if done.Err != nil {
371+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
372+ return
373+ }
374+ f.res.SendTransform(0)
375+ f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))
376+ hi, lo, ns := captureTime(done.Time)
377+ f.res.SendPresentationTime(hi, lo, ns)
378+ f.res.SendReady()
379+ }, f.res.Resource)
380+ if !ok {
381+ if !c.s.validCaptureBuffer(f.buf, region, buf.format) {
382+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints))
383+ } else {
384+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
385+ }
386+ }
387+}
@@ -0,0 +1,387 @@
1+package wayland
2+
3+import (
4+ "image"
5+ "math"
6+ "os"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+ source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
11+ ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
12+ "github.com/bnema/purego-libwayland/protocol/wayland"
13+ wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
14+ "github.com/bnema/purego-libwayland/server"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// The renderer's scene and wl_output modes are already in physical orientation;
19+// this compositor does not advertise rotated wl_output transforms.
20+func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
21+ if o == nil {
22+ return image.Rectangle{}
23+ }
24+ scale := o.place.Scale
25+ if scale <= 0 {
26+ scale = 1
27+ }
28+ p := image.Rect(int(math.Floor(float64(logical.Min.X)*scale)), int(math.Floor(float64(logical.Min.Y)*scale)), int(math.Ceil(float64(logical.Max.X)*scale)), int(math.Ceil(float64(logical.Max.Y)*scale)))
29+ return p.Intersect(image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height))
30+}
31+func captureTime(t time.Time) (uint32, uint32, uint32) {
32+ sec := uint64(t.Unix())
33+ return uint32(sec >> 32), uint32(sec), uint32(t.Nanosecond())
34+}
35+func registerCapture(d *server.Display, s *Server) error {
36+ if err := wlr.NewZwlrScreencopyManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
37+ _, _ = wlr.NewZwlrScreencopyManagerV1(c, int32(v), id, screencopyManager{s})
38+ }); err != nil {
39+ return err
40+ }
41+ if err := source.NewExtOutputImageCaptureSourceManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
42+ _, _ = source.NewExtOutputImageCaptureSourceManagerV1(c, int32(v), id, outputCaptureSource{s})
43+ }); err != nil {
44+ return err
45+ }
46+ return ext.NewExtImageCopyCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
47+ _, _ = ext.NewExtImageCopyCaptureManagerV1(c, int32(v), id, copyCaptureManager{s})
48+ })
49+}
50+
51+type screencopyManager struct{ s *Server }
52+
53+func (screencopyManager) Destroy(*wlr.ZwlrScreencopyManagerV1) {}
54+func (m screencopyManager) CaptureOutput(r *wlr.ZwlrScreencopyManagerV1, id uint32, cursor int32, out *wayland.Output) {
55+ o := m.s.outputOf(out)
56+ if o == nil {
57+ m.create(r, id, cursor, nil, image.Rectangle{})
58+ return
59+ }
60+ m.create(r, id, cursor, o, image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height))
61+}
62+func (m screencopyManager) CaptureOutputRegion(r *wlr.ZwlrScreencopyManagerV1, id uint32, cursor int32, out *wayland.Output, x, y, w, h int32) {
63+ o := m.s.outputOf(out)
64+ if w <= 0 || h <= 0 {
65+ m.create(r, id, cursor, o, image.Rectangle{})
66+ return
67+ }
68+ m.create(r, id, cursor, o, captureRegion(o, image.Rect(int(x), int(y), int64Clamp(int64(x)+int64(w)), int64Clamp(int64(y)+int64(h)))))
69+}
70+func int64Clamp(x int64) int {
71+ if x > int64(^uint(0)>>1) {
72+ return int(^uint(0) >> 1)
73+ }
74+ return int(x)
75+}
76+func (m screencopyManager) create(r *wlr.ZwlrScreencopyManagerV1, id uint32, cursor int32, o *output, rect image.Rectangle) {
77+ state := &screencopyFrame{s: m.s, o: o, region: rect, cursor: cursor != 0}
78+ f, err := wlr.NewZwlrScreencopyFrameV1(r.Client(), r.Version(), id, state)
79+ if err != nil {
80+ return
81+ }
82+ state.res = f
83+ if rect.Empty() {
84+ f.SendFailed()
85+ state.used = true
86+ return
87+ }
88+ f.SendBuffer(uint32(wayland.ShmFormatXrgb8888), uint32(rect.Dx()), uint32(rect.Dy()), uint32(rect.Dx()*4))
89+ if f.Version() >= 3 {
90+ f.SendBufferDone()
91+ }
92+}
93+
94+type screencopyFrame struct {
95+ s *Server
96+ res *wlr.ZwlrScreencopyFrameV1
97+ o *output
98+ region image.Rectangle
99+ cursor, used bool
100+ replyID uint64
101+}
102+
103+func (f *screencopyFrame) Destroy(*wlr.ZwlrScreencopyFrameV1) { delete(f.s.captureReplies, f.replyID) }
104+func (f *screencopyFrame) Copy(_ *wlr.ZwlrScreencopyFrameV1, b *wayland.Buffer) { f.copy(b, false) }
105+func (f *screencopyFrame) CopyWithDamage(_ *wlr.ZwlrScreencopyFrameV1, b *wayland.Buffer) {
106+ f.copy(b, true)
107+}
108+func (f *screencopyFrame) copy(b *wayland.Buffer, damage bool) {
109+ if f.used {
110+ f.res.PostError(uint32(wlr.ZwlrScreencopyFrameV1ErrorAlreadyUsed), "frame already used")
111+ return
112+ }
113+ f.used = true
114+ if f.o == nil || f.region.Empty() || f.s.outputByNameExact(f.o.name()) != f.o {
115+ f.res.SendFailed()
116+ return
117+ }
118+ if !f.s.validCaptureBuffer(b, f.region, uint32(wayland.ShmFormatXrgb8888)) {
119+ f.res.PostError(uint32(wlr.ZwlrScreencopyFrameV1ErrorInvalidBuffer), "invalid shm buffer")
120+ return
121+ }
122+ id, ok := f.s.requestCapture(f.o, f.region, f.cursor, b, uint32(wayland.ShmFormatXrgb8888), func(done ports.CaptureDone) {
123+ if done.Err != nil {
124+ f.res.SendFailed()
125+ return
126+ }
127+ f.res.SendFlags(0)
128+ if damage && f.res.Version() >= 2 {
129+ f.res.SendDamage(0, 0, uint32(f.region.Dx()), uint32(f.region.Dy()))
130+ }
131+ hi, lo, ns := captureTime(done.Time)
132+ f.res.SendReady(hi, lo, ns)
133+ }, f.res.Resource)
134+ f.replyID = id
135+ if !ok {
136+ f.res.SendFailed()
137+ }
138+}
139+
140+// validCaptureBuffer checks the advertised shm constraints before any request is queued.
141+func (s *Server) validCaptureBuffer(b *wayland.Buffer, rect image.Rectangle, format uint32) bool {
142+ if b == nil {
143+ return false
144+ }
145+ buf, ok := s.buffers[b.Resource].(*buffer)
146+ return ok && (format == uint32(wayland.ShmFormatArgb8888) || format == uint32(wayland.ShmFormatXrgb8888)) && buf.width == rect.Dx() && buf.height == rect.Dy() && buf.stride >= rect.Dx()*4 && buf.format == format && fileHolds(buf.pool.file, int64(buf.offset)+int64(buf.height-1)*int64(buf.stride)+int64(buf.width)*4)
147+}
148+
149+// requestCapture duplicates the pool descriptor, so destroying the wl_buffer
150+// cannot invalidate a capture in flight.
151+func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b *wayland.Buffer, format uint32, reply func(ports.CaptureDone), life *server.Resource) (uint64, bool) {
152+ if o == nil || b == nil || s.channels.Captures == nil || s.channels.Captured == nil || s.outputByNameExact(o.name()) != o {
153+ return 0, false
154+ }
155+ if !s.validCaptureBuffer(b, rect, format) {
156+ s.log.Debug().Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Msg("capture buffer rejected")
157+ return 0, false
158+ }
159+ buf := s.buffers[b.Resource].(*buffer)
160+ fd, err := unix.Dup(int(buf.pool.file.Fd()))
161+ if err != nil {
162+ s.log.Warn().Err(err).Str("output", o.name()).Msg("capture: dup shm fd")
163+ return 0, false
164+ }
165+ s.nextCapture++
166+ id := s.nextCapture
167+ start := time.Now()
168+ s.captureReplies[id] = func(done ports.CaptureDone) {
169+ ev := s.log.Debug()
170+ if done.Err != nil {
171+ ev = s.log.Info().Err(done.Err)
172+ }
173+ ev.Uint64("id", id).Str("output", done.Output).Dur("took", time.Since(start)).Bool("delivered", life.Alive()).Msg("capture done")
174+ if life.Alive() {
175+ reply(done)
176+ }
177+ }
178+ 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}
179+ select {
180+ case s.channels.Captures <- req:
181+ s.log.Debug().Uint64("id", id).Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Bool("cursor", cursor).Msg("capture requested")
182+ return id, true
183+ default:
184+ delete(s.captureReplies, id)
185+ req.Dst.File.Close()
186+ s.log.Warn().Str("output", o.name()).Msg("capture refused: request queue full")
187+ return 0, false
188+ }
189+}
190+func (s *Server) forwardCaptured(ctxDone <-chan struct{}) {
191+ for {
192+ select {
193+ case <-ctxDone:
194+ return
195+ case <-s.display.Stopped():
196+ return
197+ case done, ok := <-s.channels.Captured:
198+ if !ok {
199+ return
200+ }
201+ if !s.display.Do(func() {
202+ if fn := s.captureReplies[done.ID]; fn != nil {
203+ delete(s.captureReplies, done.ID)
204+ fn(done)
205+ }
206+ }) {
207+ return
208+ }
209+ }
210+ }
211+}
212+
213+type outputCaptureSource struct{ s *Server }
214+
215+func (outputCaptureSource) Destroy(*source.ExtOutputImageCaptureSourceManagerV1) {}
216+func (m outputCaptureSource) CreateSource(r *source.ExtOutputImageCaptureSourceManagerV1, id uint32, out *wayland.Output) {
217+ src, err := source.NewExtImageCaptureSourceV1(r.Client(), 1, id, captureSourceHandler{})
218+ if err != nil {
219+ return
220+ }
221+ m.s.captureSources[src.Resource] = m.s.outputOf(out)
222+ src.OnDestroy = func() { delete(m.s.captureSources, src.Resource) }
223+}
224+
225+type captureSourceHandler struct{}
226+
227+func (captureSourceHandler) Destroy(*source.ExtImageCaptureSourceV1) {}
228+
229+type copyCaptureManager struct{ s *Server }
230+
231+func (copyCaptureManager) Destroy(*ext.ExtImageCopyCaptureManagerV1) {}
232+func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, id uint32, src *source.ExtImageCaptureSourceV1, options uint32) {
233+ var o *output
234+ if options&^1 != 0 {
235+ r.PostError(uint32(ext.ExtImageCopyCaptureManagerV1ErrorInvalidOption), "invalid capture option")
236+ return
237+ }
238+ if src != nil {
239+ o = m.s.captureSources[src.Resource]
240+ }
241+ state := &captureSession{s: m.s, o: o, cursor: options&1 != 0}
242+ res, err := ext.NewExtImageCopyCaptureSessionV1(r.Client(), 1, id, state)
243+ if err != nil {
244+ return
245+ }
246+ state.res = res
247+ m.s.captureSessions[state] = struct{}{}
248+ // Frames outlive their session (the spec): each frame drops its own reply.
249+ res.OnDestroy = func() { delete(m.s.captureSessions, state) }
250+ if o == nil {
251+ res.SendStopped()
252+ state.stopped = true
253+ return
254+ }
255+ state.sendConstraints(o.place.Info.Width, o.place.Info.Height)
256+}
257+
258+// sendConstraints sends one complete constraint batch: formats, size, done.
259+func (c *captureSession) sendConstraints(w, h int) {
260+ c.res.SendBufferSize(uint32(w), uint32(h))
261+ c.res.SendShmFormat(uint32(wayland.ShmFormatXrgb8888))
262+ c.res.SendShmFormat(uint32(wayland.ShmFormatArgb8888))
263+ c.res.SendDone()
264+}
265+func (m copyCaptureManager) CreatePointerCursorSession(r *ext.ExtImageCopyCaptureManagerV1, id uint32, _ *source.ExtImageCaptureSourceV1, _ *wayland.Pointer) {
266+ state := &cursorCaptureSession{}
267+ res, err := ext.NewExtImageCopyCaptureCursorSessionV1(r.Client(), 1, id, state)
268+ if err == nil {
269+ state.res = res
270+ }
271+}
272+
273+type cursorCaptureSession struct {
274+ res *ext.ExtImageCopyCaptureCursorSessionV1
275+ used bool
276+}
277+
278+func (*cursorCaptureSession) Destroy(*ext.ExtImageCopyCaptureCursorSessionV1) {}
279+func (c *cursorCaptureSession) GetCaptureSession(_ *ext.ExtImageCopyCaptureCursorSessionV1, id uint32) {
280+ if c.used {
281+ c.res.PostError(uint32(ext.ExtImageCopyCaptureCursorSessionV1ErrorDuplicateSession), "session already created")
282+ return
283+ }
284+ c.used = true
285+ state := &captureSession{stopped: true}
286+ res, err := ext.NewExtImageCopyCaptureSessionV1(c.res.Client(), 1, id, state)
287+ if err == nil {
288+ state.res = res
289+ res.SendStopped()
290+ }
291+}
292+
293+type captureSession struct {
294+ s *Server
295+ o *output
296+ res *ext.ExtImageCopyCaptureSessionV1
297+ cursor, stopped bool
298+ frame *captureExtFrame
299+}
300+
301+func (*captureSession) Destroy(*ext.ExtImageCopyCaptureSessionV1) {}
302+func (c *captureSession) CreateFrame(r *ext.ExtImageCopyCaptureSessionV1, id uint32) {
303+ if c.frame != nil {
304+ r.PostError(uint32(ext.ExtImageCopyCaptureSessionV1ErrorDuplicateFrame), "frame still alive")
305+ return
306+ }
307+ state := &captureExtFrame{session: c}
308+ res, err := ext.NewExtImageCopyCaptureFrameV1(r.Client(), 1, id, state)
309+ if err == nil {
310+ state.res = res
311+ c.frame = state
312+ res.OnDestroy = func() {
313+ if c.frame == state {
314+ c.frame = nil
315+ }
316+ if c.s != nil {
317+ delete(c.s.captureReplies, state.replyID)
318+ }
319+ }
320+ }
321+}
322+
323+type captureExtFrame struct {
324+ session *captureSession
325+ res *ext.ExtImageCopyCaptureFrameV1
326+ buf *wayland.Buffer
327+ used bool
328+ replyID uint64
329+}
330+
331+func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}
332+func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {
333+ if f.used {
334+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
335+ return
336+ }
337+ f.buf = b
338+}
339+func (f *captureExtFrame) DamageBuffer(_ *ext.ExtImageCopyCaptureFrameV1, x, y, w, h int32) {
340+ if f.used {
341+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
342+ return
343+ }
344+ if x < 0 || y < 0 || w <= 0 || h <= 0 {
345+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorInvalidBufferDamage), "invalid damage")
346+ }
347+}
348+func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
349+ if f.used {
350+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured), "already captured")
351+ return
352+ }
353+ f.used = true
354+ c := f.session
355+ if c.stopped || c.o == nil || c.s.outputByNameExact(c.o.name()) != c.o {
356+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
357+ return
358+ }
359+ region := image.Rect(0, 0, c.o.place.Info.Width, c.o.place.Info.Height)
360+ if f.buf == nil {
361+ f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")
362+ return
363+ }
364+ buf, ok := c.s.buffers[f.buf.GetResource()].(*buffer)
365+ if !ok {
366+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints))
367+ return
368+ }
369+ f.replyID, ok = c.s.requestCapture(c.o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {
370+ if done.Err != nil {
371+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
372+ return
373+ }
374+ f.res.SendTransform(0)
375+ f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))
376+ hi, lo, ns := captureTime(done.Time)
377+ f.res.SendPresentationTime(hi, lo, ns)
378+ f.res.SendReady()
379+ }, f.res.Resource)
380+ if !ok {
381+ if !c.s.validCaptureBuffer(f.buf, region, buf.format) {
382+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints))
383+ } else {
384+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
385+ }
386+ }
387+}
A internal/adapters/wayland/screencopy_test.go +714 -0
@@ -0,0 +1,714 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "fmt"
6+ source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
7+ ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
8+ "image"
9+ "reflect"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/neferwl/internal/logging"
15+ "github.com/bnema/neferwl/internal/ports"
16+ "github.com/bnema/purego-libwayland/protocol/wayland"
17+ wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
18+ "github.com/bnema/wlturbo"
19+ "golang.org/x/sys/unix"
20+)
21+
22+type captureEvents struct {
23+ wlturbo.BaseProxy
24+ events chan uint16
25+}
26+
27+func (p *captureEvents) Dispatch(e *wlturbo.Event) { p.events <- e.Opcode }
28+func TestCaptureRegion(t *testing.T) {
29+ o := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Width: 300, Height: 200}, Scale: 1.5}}
30+ for _, tc := range []struct{ in, want image.Rectangle }{
31+ {image.Rect(0, 0, 100, 100), image.Rect(0, 0, 150, 150)},
32+ {image.Rect(-10, -10, 10, 10), image.Rect(0, 0, 15, 15)},
33+ {image.Rect(190, 120, 240, 160), image.Rect(285, 180, 300, 200)},
34+ } {
35+ if got := captureRegion(o, tc.in); got != tc.want {
36+ t.Errorf("%v: %v != %v", tc.in, got, tc.want)
37+ }
38+ }
39+}
40+func TestScreencopyProtocol(t *testing.T) {
41+ dir := t.TempDir()
42+ requests := make(chan ports.CaptureRequest, 1)
43+ replies := make(chan ports.CaptureDone, 1)
44+ s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}}}, Channels{Captures: requests, Captured: replies}, logging.For(context.Background(), "wayland"))
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ ctx, cancel := context.WithCancel(context.Background())
49+ done := make(chan error, 1)
50+ go func() { done <- s.Run(ctx) }()
51+ defer func() {
52+ cancel()
53+ if err := <-done; err != nil {
54+ t.Error(err)
55+ }
56+ }()
57+ c := protocolClient(t, s, dir)
58+ var out uint32
59+ for name, g := range c.Registry().GetGlobals() {
60+ if g.Interface == "wl_output" {
61+ out = name
62+ }
63+ }
64+ if out == 0 {
65+ t.Fatal("output global missing")
66+ }
67+ outputID, err := c.Registry().BindID(out, "wl_output", 4)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ registerProtocol(t, c, outputID)
72+ manager := bindVersion(t, c, "zwlr_screencopy_manager_v1", 3)
73+ frame := c.AllocateID()
74+ events := &captureEvents{events: make(chan uint16, 8)}
75+ events.SetID(frame)
76+ c.Context().Register(events)
77+ requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutputRegion, frame, int32(0), outputID, int32(1), int32(1), int32(2), int32(2))
78+ if err := c.Roundtrip(); err != nil {
79+ t.Fatal(err)
80+ }
81+ if op := <-events.events; op != uint16(wlr.ZwlrScreencopyFrameV1EventBuffer) {
82+ t.Fatalf("buffer: %d", op)
83+ }
84+ if op := <-events.events; op != uint16(wlr.ZwlrScreencopyFrameV1EventBufferDone) {
85+ t.Fatalf("done: %d", op)
86+ }
87+ shm := bindProtocol(t, c, "wl_shm")
88+ registerProtocol(t, c, shm)
89+ fd, err := unix.MemfdCreate("capture", 0)
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ defer unix.Close(fd)
94+ if err := unix.Ftruncate(fd, 16); err != nil {
95+ t.Fatal(err)
96+ }
97+ pool, buf := c.AllocateID(), c.AllocateID()
98+ registerProtocol(t, c, pool)
99+ registerProtocol(t, c, buf)
100+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil {
101+ t.Fatal(err)
102+ }
103+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))
104+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestCopyWithDamage, buf)
105+ if err := c.Roundtrip(); err != nil {
106+ t.Fatal(err)
107+ }
108+ select {
109+ case req := <-requests:
110+ if req.Region != image.Rect(1, 1, 3, 3) {
111+ t.Errorf("region: %v", req.Region)
112+ }
113+ if _, err := req.Dst.File.WriteAt([]byte{1, 2, 3, 255}, 0); err != nil {
114+ t.Fatal(err)
115+ }
116+ if err := req.Dst.File.Close(); err != nil {
117+ t.Fatal(err)
118+ }
119+ replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Time: time.Now()}
120+ if !s.display.Do(func() {}) {
121+ t.Fatal("display stopped")
122+ }
123+ case <-time.After(2 * time.Second):
124+ t.Fatal("no request")
125+ }
126+ if err := c.Roundtrip(); err != nil {
127+ t.Fatal(err)
128+ }
129+ for _, want := range []uint16{uint16(wlr.ZwlrScreencopyFrameV1EventFlags), uint16(wlr.ZwlrScreencopyFrameV1EventDamage), uint16(wlr.ZwlrScreencopyFrameV1EventReady)} {
130+ var got uint16
131+ select {
132+ case got = <-events.events:
133+ case <-time.After(2 * time.Second):
134+ t.Fatalf("missing event %d", want)
135+ }
136+ if got != want {
137+ t.Errorf("event %d, want %d", got, want)
138+ }
139+ }
140+ data := make([]byte, 4)
141+ if _, err := unix.Pread(fd, data, 0); err != nil {
142+ t.Fatal(err)
143+ }
144+ if data[0] != 1 {
145+ t.Fatal(data)
146+ }
147+}
148+
149+func captureTestClient(t *testing.T) (*Server, *wlturbo.Display, uint32) {
150+ t.Helper()
151+ s, _, _, dir := lifecycleServer(t)
152+ c := protocolClient(t, s, dir)
153+ g, ok := c.Registry().FindGlobal("wl_output")
154+ if !ok {
155+ t.Fatal("wl_output missing")
156+ }
157+ out, err := c.Registry().BindID(g.Name, g.Interface, 4)
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ registerProtocol(t, c, out)
162+ if err := c.Roundtrip(); err != nil {
163+ t.Fatal(err)
164+ }
165+ return s, c, out
166+}
167+
168+func captureTestExt(t *testing.T, c *wlturbo.Display, out uint32) (uint32, uint32, uint32) {
169+ t.Helper()
170+ sourceManager := bindProtocol(t, c, "ext_output_image_capture_source_manager_v1")
171+ manager := bindProtocol(t, c, "ext_image_copy_capture_manager_v1")
172+ src, session := c.AllocateID(), c.AllocateID()
173+ registerProtocol(t, c, src)
174+ requestProtocol(t, c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
175+ events := &captureEvents{events: make(chan uint16, 16)}
176+ events.SetID(session)
177+ c.Context().Register(events)
178+ requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
179+ if err := c.Roundtrip(); err != nil {
180+ t.Fatal(err)
181+ }
182+ for _, want := range []uint16{0, 1, 1, 4} {
183+ if got := <-events.events; got != want {
184+ t.Fatalf("session event %d != %d", got, want)
185+ }
186+ }
187+ return manager, session, src
188+}
189+
190+func TestCaptureProtocolErrors(t *testing.T) {
191+ for _, tc := range []struct {
192+ name string
193+ object string
194+ code uint32
195+ }{
196+ {"wlr already used", "wlr", uint32(wlr.ZwlrScreencopyFrameV1ErrorAlreadyUsed)},
197+ {"wlr invalid size", "wlr size", uint32(wlr.ZwlrScreencopyFrameV1ErrorInvalidBuffer)},
198+ {"wlr invalid format", "wlr format", uint32(wlr.ZwlrScreencopyFrameV1ErrorInvalidBuffer)},
199+ {"ext no buffer", "frame", uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer)},
200+ {"ext already captured", "frame twice", uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured)},
201+ {"ext damage", "damage", uint32(ext.ExtImageCopyCaptureFrameV1ErrorInvalidBufferDamage)},
202+ {"ext duplicate frame", "session", uint32(ext.ExtImageCopyCaptureSessionV1ErrorDuplicateFrame)},
203+ {"ext invalid option", "manager", uint32(ext.ExtImageCopyCaptureManagerV1ErrorInvalidOption)},
204+ {"ext duplicate cursor session", "cursor", uint32(ext.ExtImageCopyCaptureCursorSessionV1ErrorDuplicateSession)},
205+ {"ext buffer constraints", "constraints", 0},
206+ {"ext attach after capture", "attach after", uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured)},
207+ {"ext damage after capture", "damage after", uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured)},
208+ } {
209+ t.Run(tc.name, func(t *testing.T) {
210+ _, c, out := captureTestClient(t)
211+ if strings.HasPrefix(tc.object, "wlr") {
212+ manager := bindVersion(t, c, "zwlr_screencopy_manager_v1", 3)
213+ frame := c.AllocateID()
214+ registerProtocol(t, c, frame)
215+ requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), out)
216+ if err := c.Roundtrip(); err != nil {
217+ t.Fatal(err)
218+ }
219+ shm := bindProtocol(t, c, "wl_shm")
220+ fd, err := unix.MemfdCreate("bad-capture", 0)
221+ if err != nil {
222+ t.Fatal(err)
223+ }
224+ defer unix.Close(fd)
225+ if err := unix.Ftruncate(fd, int64(testOutputs[0].Info.Width*testOutputs[0].Info.Height*4)); err != nil {
226+ t.Fatal(err)
227+ }
228+ pool, buf := c.AllocateID(), c.AllocateID()
229+ registerProtocol(t, c, pool)
230+ registerProtocol(t, c, buf)
231+ registerProtocol(t, c, shm)
232+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(testOutputs[0].Info.Width*testOutputs[0].Info.Height*4)); err != nil {
233+ t.Fatal(err)
234+ }
235+ w := int32(1)
236+ format := uint32(wayland.ShmFormatXrgb8888)
237+ if tc.object == "wlr format" {
238+ format = uint32(wayland.ShmFormatArgb8888)
239+ }
240+ if tc.object != "wlr size" {
241+ w = int32(testOutputs[0].Info.Width)
242+ }
243+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), w, int32(testOutputs[0].Info.Height), w*4, format)
244+ if tc.object == "wlr" {
245+ if err := c.Roundtrip(); err != nil {
246+ t.Fatal(err)
247+ }
248+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
249+ if err := c.Roundtrip(); err != nil {
250+ t.Fatal(err)
251+ }
252+ }
253+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
254+ expectProtocolError(t, c, frame, tc.code)
255+ return
256+ }
257+ manager, session, src := captureTestExt(t, c, out)
258+ switch tc.object {
259+ case "manager":
260+ requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, c.AllocateID(), src, uint32(2))
261+ expectProtocolError(t, c, manager, tc.code)
262+ return
263+ case "cursor":
264+ seat := bindProtocol(t, c, "wl_seat")
265+ pointer := c.AllocateID()
266+ registerProtocol(t, c, pointer)
267+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
268+ registerProtocol(t, c, seat)
269+ cursor := c.AllocateID()
270+ registerProtocol(t, c, cursor)
271+ requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreatePointerCursorSession, cursor, src, pointer)
272+ registerProtocol(t, c, manager)
273+ sessionID := c.AllocateID()
274+ registerProtocol(t, c, sessionID)
275+ requestProtocol(t, c, cursor, ext.ExtImageCopyCaptureCursorSessionV1RequestGetCaptureSession, sessionID)
276+ if err := c.Roundtrip(); err != nil {
277+ t.Fatal(err)
278+ }
279+ requestProtocol(t, c, cursor, ext.ExtImageCopyCaptureCursorSessionV1RequestGetCaptureSession, c.AllocateID())
280+ expectProtocolError(t, c, cursor, tc.code)
281+ return
282+ }
283+ frame := c.AllocateID()
284+ frameEvents := &captureDetails{events: make(chan []uint32, 4)}
285+ frameEvents.SetID(frame)
286+ c.Context().Register(frameEvents)
287+ requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
288+ switch tc.object {
289+ case "frame":
290+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
291+ case "constraints", "attach after", "damage after", "frame twice":
292+ shm := bindProtocol(t, c, "wl_shm")
293+ registerProtocol(t, c, shm)
294+ fd, err := unix.MemfdCreate("ext-capture", 0)
295+ if err != nil {
296+ t.Fatal(err)
297+ }
298+ defer unix.Close(fd)
299+ if err := unix.Ftruncate(fd, 4); err != nil {
300+ t.Fatal(err)
301+ }
302+ pool, buf := c.AllocateID(), c.AllocateID()
303+ registerProtocol(t, c, pool)
304+ registerProtocol(t, c, buf)
305+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
306+ t.Fatal(err)
307+ }
308+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(wayland.ShmFormatXrgb8888))
309+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
310+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
311+ if tc.object == "constraints" {
312+ if err := c.Roundtrip(); err != nil {
313+ t.Fatal(err)
314+ }
315+ if got := <-frameEvents.events; !reflect.DeepEqual(got, []uint32{uint32(ext.ExtImageCopyCaptureFrameV1EventFailed), uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints)}) {
316+ t.Fatalf("constraints: event %v", got)
317+ }
318+ return
319+ }
320+ switch tc.object {
321+ case "frame twice":
322+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
323+ case "attach after":
324+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
325+ case "damage after":
326+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestDamageBuffer, int32(0), int32(0), int32(1), int32(1))
327+ }
328+ case "damage":
329+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestDamageBuffer, int32(-1), int32(0), int32(1), int32(1))
330+ case "session":
331+ requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, c.AllocateID())
332+ }
333+ object := frame
334+ if tc.object == "session" {
335+ object = session
336+ }
337+ expectProtocolError(t, c, object, tc.code)
338+ })
339+ }
340+}
341+
342+// captureDetails records protocol payloads, not just the event opcodes.
343+type captureDetails struct {
344+ wlturbo.BaseProxy
345+ events chan []uint32
346+}
347+
348+func (p *captureDetails) Dispatch(e *wlturbo.Event) {
349+ n := 0
350+ switch e.Opcode {
351+ case 0:
352+ n = 2
353+ case 1:
354+ n = 1
355+ if len(e.Data()) >= 16 {
356+ n = 4
357+ }
358+ case 2:
359+ n = 3
360+ case 4:
361+ if len(e.Data()) >= 4 {
362+ n = 1
363+ }
364+ }
365+ values := []uint32{uint32(e.Opcode)}
366+ for i := 0; i < n; i++ {
367+ values = append(values, e.Uint32())
368+ }
369+ p.events <- values
370+}
371+func TestExtCaptureLifecycle(t *testing.T) {
372+ dir := t.TempDir()
373+ requests := make(chan ports.CaptureRequest, 1)
374+ replies := make(chan ports.CaptureDone, 1)
375+ commands := make(chan ports.ClientCommand, 4)
376+ initial := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}
377+ s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{initial}}, Channels{Captures: requests, Captured: replies, Commands: commands}, logging.For(context.Background(), "wayland"))
378+ if err != nil {
379+ t.Fatal(err)
380+ }
381+ ctx, cancel := context.WithCancel(context.Background())
382+ done := make(chan error, 1)
383+ go func() { done <- s.Run(ctx) }()
384+ defer func() {
385+ cancel()
386+ if err := <-done; err != nil {
387+ t.Error(err)
388+ }
389+ }()
390+ c := protocolClient(t, s, dir)
391+ g, ok := c.Registry().FindGlobal("wl_output")
392+ if !ok {
393+ t.Fatal("output missing")
394+ }
395+ out, err := c.Registry().BindID(g.Name, g.Interface, 4)
396+ if err != nil {
397+ t.Fatal(err)
398+ }
399+ registerProtocol(t, c, out)
400+ srcMgr := bindProtocol(t, c, "ext_output_image_capture_source_manager_v1")
401+ mgr := bindProtocol(t, c, "ext_image_copy_capture_manager_v1")
402+ src, session := c.AllocateID(), c.AllocateID()
403+ registerProtocol(t, c, src)
404+ p := &captureDetails{events: make(chan []uint32, 16)}
405+ p.SetID(session)
406+ c.Context().Register(p)
407+ requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
408+ requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
409+ if err := c.Roundtrip(); err != nil {
410+ t.Fatal(err)
411+ }
412+ for _, want := range [][]uint32{{0, 4, 4}, {1, uint32(wayland.ShmFormatXrgb8888)}, {1, uint32(wayland.ShmFormatArgb8888)}, {4}} {
413+ got := <-p.events
414+ if !reflect.DeepEqual(got, want) {
415+ t.Fatalf("session event %v != %v", got, want)
416+ }
417+ }
418+ shm := bindProtocol(t, c, "wl_shm")
419+ registerProtocol(t, c, shm)
420+ fd, err := unix.MemfdCreate("ext-capture", 0)
421+ if err != nil {
422+ t.Fatal(err)
423+ }
424+ defer unix.Close(fd)
425+ // Room for the 4x4 and the resized 5x4 buffers.
426+ if err := unix.Ftruncate(fd, 80); err != nil {
427+ t.Fatal(err)
428+ }
429+ pool, buf := c.AllocateID(), c.AllocateID()
430+ registerProtocol(t, c, pool)
431+ registerProtocol(t, c, buf)
432+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(80)); err != nil {
433+ t.Fatal(err)
434+ }
435+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(4), int32(4), int32(16), uint32(wayland.ShmFormatXrgb8888))
436+ frame := c.AllocateID()
437+ fp := &captureDetails{events: make(chan []uint32, 16)}
438+ fp.SetID(frame)
439+ c.Context().Register(fp)
440+ requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
441+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
442+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
443+ if err := c.Roundtrip(); err != nil {
444+ t.Fatal(err)
445+ }
446+ var req ports.CaptureRequest
447+ select {
448+ case req = <-requests:
449+ case <-time.After(2 * time.Second):
450+ t.Fatal("no capture request")
451+ }
452+ if req.Width != 4 || req.Height != 4 {
453+ t.Fatalf("request %+v", req)
454+ }
455+ if err := req.Dst.File.Close(); err != nil {
456+ t.Fatal(err)
457+ }
458+ var now unix.Timespec
459+ if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil {
460+ t.Fatal(err)
461+ }
462+ replies <- ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)}
463+ if !s.display.Do(func() {}) {
464+ t.Fatal("display stopped")
465+ }
466+ if err := c.Roundtrip(); err != nil {
467+ t.Fatal(err)
468+ }
469+ for _, want := range [][]uint32{{0, 0, 0}, {1, 0, 0, 4, 4}, {2, 0, uint32(now.Sec), uint32(now.Nsec)}, {3}} {
470+ got := <-fp.events
471+ if !reflect.DeepEqual(got, want) {
472+ t.Fatalf("frame event %v != %v", got, want)
473+ }
474+ }
475+ resized := initial
476+ resized.Info.Width = 5
477+ resized.Width = 5
478+ commands <- ports.SetOutputs{Outputs: ports.Layout{resized}}
479+ if !s.display.Do(func() {}) {
480+ t.Fatal("display stopped")
481+ }
482+ if err := c.Roundtrip(); err != nil {
483+ t.Fatal(err)
484+ }
485+ // A resize sends a complete constraint batch, formats included.
486+ for _, want := range [][]uint32{{0, 5, 4}, {1, uint32(wayland.ShmFormatXrgb8888)}, {1, uint32(wayland.ShmFormatArgb8888)}, {4}} {
487+ got := <-p.events
488+ if !reflect.DeepEqual(got, want) {
489+ t.Fatalf("resize event %v != %v", got, want)
490+ }
491+ }
492+ // Destroying the session leaves its pending frame alive: it still gets ready.
493+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestDestroy) // one live frame per session
494+ buf2, frame2 := c.AllocateID(), c.AllocateID()
495+ registerProtocol(t, c, buf2)
496+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf2, int32(0), int32(5), int32(4), int32(20), uint32(wayland.ShmFormatXrgb8888))
497+ fp2 := &captureDetails{events: make(chan []uint32, 16)}
498+ fp2.SetID(frame2)
499+ c.Context().Register(fp2)
500+ requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame2)
501+ requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf2)
502+ requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestCapture)
503+ if err := c.Roundtrip(); err != nil {
504+ t.Fatal(err)
505+ }
506+ select {
507+ case req = <-requests:
508+ case <-time.After(2 * time.Second):
509+ t.Fatal("no capture request after resize")
510+ }
511+ if err := req.Dst.File.Close(); err != nil {
512+ t.Fatal(err)
513+ }
514+ requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestDestroy)
515+ if err := c.Roundtrip(); err != nil {
516+ t.Fatal(err)
517+ }
518+ replies <- ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)}
519+ if !s.display.Do(func() {}) {
520+ t.Fatal("display stopped")
521+ }
522+ if err := c.Roundtrip(); err != nil {
523+ t.Fatal(err)
524+ }
525+ for _, want := range [][]uint32{{0, 0, 0}, {1, 0, 0, 5, 4}, {2, 0, uint32(now.Sec), uint32(now.Nsec)}, {3}} {
526+ select {
527+ case got := <-fp2.events:
528+ if !reflect.DeepEqual(got, want) {
529+ t.Fatalf("frame after session destroy: %v != %v", got, want)
530+ }
531+ case <-time.After(2 * time.Second):
532+ t.Fatalf("frame after session destroy: missing %v", want)
533+ }
534+ }
535+}
536+
537+// The remaining stop check needs a live session.
538+func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) {
539+ dir := t.TempDir()
540+ commands := make(chan ports.ClientCommand, 4)
541+ initial := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}
542+ s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{initial}}, Channels{Captures: make(chan ports.CaptureRequest, 1), Captured: make(chan ports.CaptureDone, 1), Commands: commands}, logging.For(context.Background(), "wayland"))
543+ if err != nil {
544+ t.Fatal(err)
545+ }
546+ ctx, cancel := context.WithCancel(context.Background())
547+ done := make(chan error, 1)
548+ go func() { done <- s.Run(ctx) }()
549+ defer func() {
550+ cancel()
551+ if err := <-done; err != nil {
552+ t.Error(err)
553+ }
554+ }()
555+ c := protocolClient(t, s, dir)
556+ g, ok := c.Registry().FindGlobal("wl_output")
557+ if !ok {
558+ t.Fatal("output missing")
559+ }
560+ out, err := c.Registry().BindID(g.Name, g.Interface, 4)
561+ if err != nil {
562+ t.Fatal(err)
563+ }
564+ registerProtocol(t, c, out)
565+ srcMgr := bindProtocol(t, c, "ext_output_image_capture_source_manager_v1")
566+ mgr := bindProtocol(t, c, "ext_image_copy_capture_manager_v1")
567+ src, session := c.AllocateID(), c.AllocateID()
568+ registerProtocol(t, c, src)
569+ p := &captureDetails{events: make(chan []uint32, 16)}
570+ p.SetID(session)
571+ c.Context().Register(p)
572+ requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
573+ requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
574+ if err := c.Roundtrip(); err != nil {
575+ t.Fatal(err)
576+ }
577+ for range 4 { // initial constraint batch
578+ <-p.events
579+ }
580+ commands <- ports.SetOutputs{}
581+ if !s.display.Do(func() {}) {
582+ t.Fatal("display stopped")
583+ }
584+ if err := c.Roundtrip(); err != nil {
585+ t.Fatal(err)
586+ }
587+ if got := <-p.events; !reflect.DeepEqual(got, []uint32{5}) {
588+ t.Fatalf("stopped: %v", got)
589+ }
590+}
591+
592+func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) {
593+ for _, destroy := range []bool{false, true} {
594+ t.Run(fmt.Sprint("destroy=", destroy), func(t *testing.T) {
595+ dir := t.TempDir()
596+ requests := make(chan ports.CaptureRequest, 1)
597+ replies := make(chan ports.CaptureDone, 1)
598+ commands := make(chan ports.ClientCommand, 2)
599+ initial := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}
600+ s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{initial}}, Channels{Captures: requests, Captured: replies, Commands: commands}, logging.For(context.Background(), "wayland"))
601+ if err != nil {
602+ t.Fatal(err)
603+ }
604+ ctx, cancel := context.WithCancel(context.Background())
605+ done := make(chan error, 1)
606+ go func() { done <- s.Run(ctx) }()
607+ defer func() {
608+ cancel()
609+ if err := <-done; err != nil {
610+ t.Error(err)
611+ }
612+ }()
613+ c := protocolClient(t, s, dir)
614+ g, ok := c.Registry().FindGlobal("wl_output")
615+ if !ok {
616+ t.Fatal("output missing")
617+ }
618+ out, err := c.Registry().BindID(g.Name, g.Interface, 4)
619+ if err != nil {
620+ t.Fatal(err)
621+ }
622+ registerProtocol(t, c, out)
623+ manager := bindVersion(t, c, "zwlr_screencopy_manager_v1", 3)
624+ frame := c.AllocateID()
625+ events := &captureEvents{events: make(chan uint16, 8)}
626+ events.SetID(frame)
627+ c.Context().Register(events)
628+ requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), out)
629+ if err := c.Roundtrip(); err != nil {
630+ t.Fatal(err)
631+ }
632+ if got := <-events.events; got != uint16(wlr.ZwlrScreencopyFrameV1EventBuffer) {
633+ t.Fatal(got)
634+ }
635+ if got := <-events.events; got != uint16(wlr.ZwlrScreencopyFrameV1EventBufferDone) {
636+ t.Fatal(got)
637+ }
638+ if !destroy {
639+ commands <- ports.SetOutputs{}
640+ if !s.display.Do(func() {}) {
641+ t.Fatal("display stopped")
642+ }
643+ shm, buf, fd := captureSmallBuffer(t, c)
644+ defer unix.Close(fd)
645+ _ = shm
646+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
647+ if err := c.Roundtrip(); err != nil {
648+ t.Fatal(err)
649+ }
650+ if got := <-events.events; got != uint16(wlr.ZwlrScreencopyFrameV1EventFailed) {
651+ t.Fatal(got)
652+ }
653+ return
654+ }
655+ _, buf, fd := captureSmallBuffer(t, c)
656+ defer unix.Close(fd)
657+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
658+ if err := c.Roundtrip(); err != nil {
659+ t.Fatal(err)
660+ }
661+ var req ports.CaptureRequest
662+ select {
663+ case req = <-requests:
664+ case <-time.After(2 * time.Second):
665+ t.Fatal("no request")
666+ }
667+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestDestroy)
668+ if err := c.Roundtrip(); err != nil {
669+ t.Fatal(err)
670+ }
671+ if !s.display.Do(func() {
672+ if _, ok := s.captureReplies[req.ID]; ok {
673+ t.Error("reply retained after frame destroy")
674+ }
675+ }) {
676+ t.Fatal("display stopped")
677+ }
678+ if err := req.Dst.File.Close(); err != nil {
679+ t.Fatal(err)
680+ }
681+ if _, err := req.Dst.File.Stat(); err == nil {
682+ t.Fatal("output descriptor open")
683+ }
684+ replies <- ports.CaptureDone{ID: req.ID}
685+ if !s.display.Do(func() {}) {
686+ t.Fatal("display stopped")
687+ }
688+ if err := c.Roundtrip(); err != nil {
689+ t.Fatal(err)
690+ }
691+ })
692+ }
693+}
694+
695+func captureSmallBuffer(t *testing.T, c *wlturbo.Display) (uint32, uint32, int) {
696+ t.Helper()
697+ shm := bindProtocol(t, c, "wl_shm")
698+ registerProtocol(t, c, shm)
699+ fd, err := unix.MemfdCreate("small-capture", 0)
700+ if err != nil {
701+ t.Fatal(err)
702+ }
703+ if err := unix.Ftruncate(fd, 16); err != nil {
704+ t.Fatal(err)
705+ }
706+ pool, buf := c.AllocateID(), c.AllocateID()
707+ registerProtocol(t, c, pool)
708+ registerProtocol(t, c, buf)
709+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil {
710+ t.Fatal(err)
711+ }
712+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))
713+ return shm, buf, fd
714+}
@@ -0,0 +1,714 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "fmt"
6+ source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource"
7+ ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
8+ "image"
9+ "reflect"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/neferwl/internal/logging"
15+ "github.com/bnema/neferwl/internal/ports"
16+ "github.com/bnema/purego-libwayland/protocol/wayland"
17+ wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy"
18+ "github.com/bnema/wlturbo"
19+ "golang.org/x/sys/unix"
20+)
21+
22+type captureEvents struct {
23+ wlturbo.BaseProxy
24+ events chan uint16
25+}
26+
27+func (p *captureEvents) Dispatch(e *wlturbo.Event) { p.events <- e.Opcode }
28+func TestCaptureRegion(t *testing.T) {
29+ o := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Width: 300, Height: 200}, Scale: 1.5}}
30+ for _, tc := range []struct{ in, want image.Rectangle }{
31+ {image.Rect(0, 0, 100, 100), image.Rect(0, 0, 150, 150)},
32+ {image.Rect(-10, -10, 10, 10), image.Rect(0, 0, 15, 15)},
33+ {image.Rect(190, 120, 240, 160), image.Rect(285, 180, 300, 200)},
34+ } {
35+ if got := captureRegion(o, tc.in); got != tc.want {
36+ t.Errorf("%v: %v != %v", tc.in, got, tc.want)
37+ }
38+ }
39+}
40+func TestScreencopyProtocol(t *testing.T) {
41+ dir := t.TempDir()
42+ requests := make(chan ports.CaptureRequest, 1)
43+ replies := make(chan ports.CaptureDone, 1)
44+ s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}}}, Channels{Captures: requests, Captured: replies}, logging.For(context.Background(), "wayland"))
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ ctx, cancel := context.WithCancel(context.Background())
49+ done := make(chan error, 1)
50+ go func() { done <- s.Run(ctx) }()
51+ defer func() {
52+ cancel()
53+ if err := <-done; err != nil {
54+ t.Error(err)
55+ }
56+ }()
57+ c := protocolClient(t, s, dir)
58+ var out uint32
59+ for name, g := range c.Registry().GetGlobals() {
60+ if g.Interface == "wl_output" {
61+ out = name
62+ }
63+ }
64+ if out == 0 {
65+ t.Fatal("output global missing")
66+ }
67+ outputID, err := c.Registry().BindID(out, "wl_output", 4)
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ registerProtocol(t, c, outputID)
72+ manager := bindVersion(t, c, "zwlr_screencopy_manager_v1", 3)
73+ frame := c.AllocateID()
74+ events := &captureEvents{events: make(chan uint16, 8)}
75+ events.SetID(frame)
76+ c.Context().Register(events)
77+ requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutputRegion, frame, int32(0), outputID, int32(1), int32(1), int32(2), int32(2))
78+ if err := c.Roundtrip(); err != nil {
79+ t.Fatal(err)
80+ }
81+ if op := <-events.events; op != uint16(wlr.ZwlrScreencopyFrameV1EventBuffer) {
82+ t.Fatalf("buffer: %d", op)
83+ }
84+ if op := <-events.events; op != uint16(wlr.ZwlrScreencopyFrameV1EventBufferDone) {
85+ t.Fatalf("done: %d", op)
86+ }
87+ shm := bindProtocol(t, c, "wl_shm")
88+ registerProtocol(t, c, shm)
89+ fd, err := unix.MemfdCreate("capture", 0)
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ defer unix.Close(fd)
94+ if err := unix.Ftruncate(fd, 16); err != nil {
95+ t.Fatal(err)
96+ }
97+ pool, buf := c.AllocateID(), c.AllocateID()
98+ registerProtocol(t, c, pool)
99+ registerProtocol(t, c, buf)
100+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil {
101+ t.Fatal(err)
102+ }
103+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))
104+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestCopyWithDamage, buf)
105+ if err := c.Roundtrip(); err != nil {
106+ t.Fatal(err)
107+ }
108+ select {
109+ case req := <-requests:
110+ if req.Region != image.Rect(1, 1, 3, 3) {
111+ t.Errorf("region: %v", req.Region)
112+ }
113+ if _, err := req.Dst.File.WriteAt([]byte{1, 2, 3, 255}, 0); err != nil {
114+ t.Fatal(err)
115+ }
116+ if err := req.Dst.File.Close(); err != nil {
117+ t.Fatal(err)
118+ }
119+ replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Time: time.Now()}
120+ if !s.display.Do(func() {}) {
121+ t.Fatal("display stopped")
122+ }
123+ case <-time.After(2 * time.Second):
124+ t.Fatal("no request")
125+ }
126+ if err := c.Roundtrip(); err != nil {
127+ t.Fatal(err)
128+ }
129+ for _, want := range []uint16{uint16(wlr.ZwlrScreencopyFrameV1EventFlags), uint16(wlr.ZwlrScreencopyFrameV1EventDamage), uint16(wlr.ZwlrScreencopyFrameV1EventReady)} {
130+ var got uint16
131+ select {
132+ case got = <-events.events:
133+ case <-time.After(2 * time.Second):
134+ t.Fatalf("missing event %d", want)
135+ }
136+ if got != want {
137+ t.Errorf("event %d, want %d", got, want)
138+ }
139+ }
140+ data := make([]byte, 4)
141+ if _, err := unix.Pread(fd, data, 0); err != nil {
142+ t.Fatal(err)
143+ }
144+ if data[0] != 1 {
145+ t.Fatal(data)
146+ }
147+}
148+
149+func captureTestClient(t *testing.T) (*Server, *wlturbo.Display, uint32) {
150+ t.Helper()
151+ s, _, _, dir := lifecycleServer(t)
152+ c := protocolClient(t, s, dir)
153+ g, ok := c.Registry().FindGlobal("wl_output")
154+ if !ok {
155+ t.Fatal("wl_output missing")
156+ }
157+ out, err := c.Registry().BindID(g.Name, g.Interface, 4)
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ registerProtocol(t, c, out)
162+ if err := c.Roundtrip(); err != nil {
163+ t.Fatal(err)
164+ }
165+ return s, c, out
166+}
167+
168+func captureTestExt(t *testing.T, c *wlturbo.Display, out uint32) (uint32, uint32, uint32) {
169+ t.Helper()
170+ sourceManager := bindProtocol(t, c, "ext_output_image_capture_source_manager_v1")
171+ manager := bindProtocol(t, c, "ext_image_copy_capture_manager_v1")
172+ src, session := c.AllocateID(), c.AllocateID()
173+ registerProtocol(t, c, src)
174+ requestProtocol(t, c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
175+ events := &captureEvents{events: make(chan uint16, 16)}
176+ events.SetID(session)
177+ c.Context().Register(events)
178+ requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
179+ if err := c.Roundtrip(); err != nil {
180+ t.Fatal(err)
181+ }
182+ for _, want := range []uint16{0, 1, 1, 4} {
183+ if got := <-events.events; got != want {
184+ t.Fatalf("session event %d != %d", got, want)
185+ }
186+ }
187+ return manager, session, src
188+}
189+
190+func TestCaptureProtocolErrors(t *testing.T) {
191+ for _, tc := range []struct {
192+ name string
193+ object string
194+ code uint32
195+ }{
196+ {"wlr already used", "wlr", uint32(wlr.ZwlrScreencopyFrameV1ErrorAlreadyUsed)},
197+ {"wlr invalid size", "wlr size", uint32(wlr.ZwlrScreencopyFrameV1ErrorInvalidBuffer)},
198+ {"wlr invalid format", "wlr format", uint32(wlr.ZwlrScreencopyFrameV1ErrorInvalidBuffer)},
199+ {"ext no buffer", "frame", uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer)},
200+ {"ext already captured", "frame twice", uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured)},
201+ {"ext damage", "damage", uint32(ext.ExtImageCopyCaptureFrameV1ErrorInvalidBufferDamage)},
202+ {"ext duplicate frame", "session", uint32(ext.ExtImageCopyCaptureSessionV1ErrorDuplicateFrame)},
203+ {"ext invalid option", "manager", uint32(ext.ExtImageCopyCaptureManagerV1ErrorInvalidOption)},
204+ {"ext duplicate cursor session", "cursor", uint32(ext.ExtImageCopyCaptureCursorSessionV1ErrorDuplicateSession)},
205+ {"ext buffer constraints", "constraints", 0},
206+ {"ext attach after capture", "attach after", uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured)},
207+ {"ext damage after capture", "damage after", uint32(ext.ExtImageCopyCaptureFrameV1ErrorAlreadyCaptured)},
208+ } {
209+ t.Run(tc.name, func(t *testing.T) {
210+ _, c, out := captureTestClient(t)
211+ if strings.HasPrefix(tc.object, "wlr") {
212+ manager := bindVersion(t, c, "zwlr_screencopy_manager_v1", 3)
213+ frame := c.AllocateID()
214+ registerProtocol(t, c, frame)
215+ requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), out)
216+ if err := c.Roundtrip(); err != nil {
217+ t.Fatal(err)
218+ }
219+ shm := bindProtocol(t, c, "wl_shm")
220+ fd, err := unix.MemfdCreate("bad-capture", 0)
221+ if err != nil {
222+ t.Fatal(err)
223+ }
224+ defer unix.Close(fd)
225+ if err := unix.Ftruncate(fd, int64(testOutputs[0].Info.Width*testOutputs[0].Info.Height*4)); err != nil {
226+ t.Fatal(err)
227+ }
228+ pool, buf := c.AllocateID(), c.AllocateID()
229+ registerProtocol(t, c, pool)
230+ registerProtocol(t, c, buf)
231+ registerProtocol(t, c, shm)
232+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(testOutputs[0].Info.Width*testOutputs[0].Info.Height*4)); err != nil {
233+ t.Fatal(err)
234+ }
235+ w := int32(1)
236+ format := uint32(wayland.ShmFormatXrgb8888)
237+ if tc.object == "wlr format" {
238+ format = uint32(wayland.ShmFormatArgb8888)
239+ }
240+ if tc.object != "wlr size" {
241+ w = int32(testOutputs[0].Info.Width)
242+ }
243+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), w, int32(testOutputs[0].Info.Height), w*4, format)
244+ if tc.object == "wlr" {
245+ if err := c.Roundtrip(); err != nil {
246+ t.Fatal(err)
247+ }
248+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
249+ if err := c.Roundtrip(); err != nil {
250+ t.Fatal(err)
251+ }
252+ }
253+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
254+ expectProtocolError(t, c, frame, tc.code)
255+ return
256+ }
257+ manager, session, src := captureTestExt(t, c, out)
258+ switch tc.object {
259+ case "manager":
260+ requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, c.AllocateID(), src, uint32(2))
261+ expectProtocolError(t, c, manager, tc.code)
262+ return
263+ case "cursor":
264+ seat := bindProtocol(t, c, "wl_seat")
265+ pointer := c.AllocateID()
266+ registerProtocol(t, c, pointer)
267+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
268+ registerProtocol(t, c, seat)
269+ cursor := c.AllocateID()
270+ registerProtocol(t, c, cursor)
271+ requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreatePointerCursorSession, cursor, src, pointer)
272+ registerProtocol(t, c, manager)
273+ sessionID := c.AllocateID()
274+ registerProtocol(t, c, sessionID)
275+ requestProtocol(t, c, cursor, ext.ExtImageCopyCaptureCursorSessionV1RequestGetCaptureSession, sessionID)
276+ if err := c.Roundtrip(); err != nil {
277+ t.Fatal(err)
278+ }
279+ requestProtocol(t, c, cursor, ext.ExtImageCopyCaptureCursorSessionV1RequestGetCaptureSession, c.AllocateID())
280+ expectProtocolError(t, c, cursor, tc.code)
281+ return
282+ }
283+ frame := c.AllocateID()
284+ frameEvents := &captureDetails{events: make(chan []uint32, 4)}
285+ frameEvents.SetID(frame)
286+ c.Context().Register(frameEvents)
287+ requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
288+ switch tc.object {
289+ case "frame":
290+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
291+ case "constraints", "attach after", "damage after", "frame twice":
292+ shm := bindProtocol(t, c, "wl_shm")
293+ registerProtocol(t, c, shm)
294+ fd, err := unix.MemfdCreate("ext-capture", 0)
295+ if err != nil {
296+ t.Fatal(err)
297+ }
298+ defer unix.Close(fd)
299+ if err := unix.Ftruncate(fd, 4); err != nil {
300+ t.Fatal(err)
301+ }
302+ pool, buf := c.AllocateID(), c.AllocateID()
303+ registerProtocol(t, c, pool)
304+ registerProtocol(t, c, buf)
305+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
306+ t.Fatal(err)
307+ }
308+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(wayland.ShmFormatXrgb8888))
309+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
310+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
311+ if tc.object == "constraints" {
312+ if err := c.Roundtrip(); err != nil {
313+ t.Fatal(err)
314+ }
315+ if got := <-frameEvents.events; !reflect.DeepEqual(got, []uint32{uint32(ext.ExtImageCopyCaptureFrameV1EventFailed), uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonBufferConstraints)}) {
316+ t.Fatalf("constraints: event %v", got)
317+ }
318+ return
319+ }
320+ switch tc.object {
321+ case "frame twice":
322+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
323+ case "attach after":
324+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
325+ case "damage after":
326+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestDamageBuffer, int32(0), int32(0), int32(1), int32(1))
327+ }
328+ case "damage":
329+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestDamageBuffer, int32(-1), int32(0), int32(1), int32(1))
330+ case "session":
331+ requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, c.AllocateID())
332+ }
333+ object := frame
334+ if tc.object == "session" {
335+ object = session
336+ }
337+ expectProtocolError(t, c, object, tc.code)
338+ })
339+ }
340+}
341+
342+// captureDetails records protocol payloads, not just the event opcodes.
343+type captureDetails struct {
344+ wlturbo.BaseProxy
345+ events chan []uint32
346+}
347+
348+func (p *captureDetails) Dispatch(e *wlturbo.Event) {
349+ n := 0
350+ switch e.Opcode {
351+ case 0:
352+ n = 2
353+ case 1:
354+ n = 1
355+ if len(e.Data()) >= 16 {
356+ n = 4
357+ }
358+ case 2:
359+ n = 3
360+ case 4:
361+ if len(e.Data()) >= 4 {
362+ n = 1
363+ }
364+ }
365+ values := []uint32{uint32(e.Opcode)}
366+ for i := 0; i < n; i++ {
367+ values = append(values, e.Uint32())
368+ }
369+ p.events <- values
370+}
371+func TestExtCaptureLifecycle(t *testing.T) {
372+ dir := t.TempDir()
373+ requests := make(chan ports.CaptureRequest, 1)
374+ replies := make(chan ports.CaptureDone, 1)
375+ commands := make(chan ports.ClientCommand, 4)
376+ initial := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}
377+ s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{initial}}, Channels{Captures: requests, Captured: replies, Commands: commands}, logging.For(context.Background(), "wayland"))
378+ if err != nil {
379+ t.Fatal(err)
380+ }
381+ ctx, cancel := context.WithCancel(context.Background())
382+ done := make(chan error, 1)
383+ go func() { done <- s.Run(ctx) }()
384+ defer func() {
385+ cancel()
386+ if err := <-done; err != nil {
387+ t.Error(err)
388+ }
389+ }()
390+ c := protocolClient(t, s, dir)
391+ g, ok := c.Registry().FindGlobal("wl_output")
392+ if !ok {
393+ t.Fatal("output missing")
394+ }
395+ out, err := c.Registry().BindID(g.Name, g.Interface, 4)
396+ if err != nil {
397+ t.Fatal(err)
398+ }
399+ registerProtocol(t, c, out)
400+ srcMgr := bindProtocol(t, c, "ext_output_image_capture_source_manager_v1")
401+ mgr := bindProtocol(t, c, "ext_image_copy_capture_manager_v1")
402+ src, session := c.AllocateID(), c.AllocateID()
403+ registerProtocol(t, c, src)
404+ p := &captureDetails{events: make(chan []uint32, 16)}
405+ p.SetID(session)
406+ c.Context().Register(p)
407+ requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
408+ requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
409+ if err := c.Roundtrip(); err != nil {
410+ t.Fatal(err)
411+ }
412+ for _, want := range [][]uint32{{0, 4, 4}, {1, uint32(wayland.ShmFormatXrgb8888)}, {1, uint32(wayland.ShmFormatArgb8888)}, {4}} {
413+ got := <-p.events
414+ if !reflect.DeepEqual(got, want) {
415+ t.Fatalf("session event %v != %v", got, want)
416+ }
417+ }
418+ shm := bindProtocol(t, c, "wl_shm")
419+ registerProtocol(t, c, shm)
420+ fd, err := unix.MemfdCreate("ext-capture", 0)
421+ if err != nil {
422+ t.Fatal(err)
423+ }
424+ defer unix.Close(fd)
425+ // Room for the 4x4 and the resized 5x4 buffers.
426+ if err := unix.Ftruncate(fd, 80); err != nil {
427+ t.Fatal(err)
428+ }
429+ pool, buf := c.AllocateID(), c.AllocateID()
430+ registerProtocol(t, c, pool)
431+ registerProtocol(t, c, buf)
432+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(80)); err != nil {
433+ t.Fatal(err)
434+ }
435+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(4), int32(4), int32(16), uint32(wayland.ShmFormatXrgb8888))
436+ frame := c.AllocateID()
437+ fp := &captureDetails{events: make(chan []uint32, 16)}
438+ fp.SetID(frame)
439+ c.Context().Register(fp)
440+ requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
441+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
442+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
443+ if err := c.Roundtrip(); err != nil {
444+ t.Fatal(err)
445+ }
446+ var req ports.CaptureRequest
447+ select {
448+ case req = <-requests:
449+ case <-time.After(2 * time.Second):
450+ t.Fatal("no capture request")
451+ }
452+ if req.Width != 4 || req.Height != 4 {
453+ t.Fatalf("request %+v", req)
454+ }
455+ if err := req.Dst.File.Close(); err != nil {
456+ t.Fatal(err)
457+ }
458+ var now unix.Timespec
459+ if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil {
460+ t.Fatal(err)
461+ }
462+ replies <- ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)}
463+ if !s.display.Do(func() {}) {
464+ t.Fatal("display stopped")
465+ }
466+ if err := c.Roundtrip(); err != nil {
467+ t.Fatal(err)
468+ }
469+ for _, want := range [][]uint32{{0, 0, 0}, {1, 0, 0, 4, 4}, {2, 0, uint32(now.Sec), uint32(now.Nsec)}, {3}} {
470+ got := <-fp.events
471+ if !reflect.DeepEqual(got, want) {
472+ t.Fatalf("frame event %v != %v", got, want)
473+ }
474+ }
475+ resized := initial
476+ resized.Info.Width = 5
477+ resized.Width = 5
478+ commands <- ports.SetOutputs{Outputs: ports.Layout{resized}}
479+ if !s.display.Do(func() {}) {
480+ t.Fatal("display stopped")
481+ }
482+ if err := c.Roundtrip(); err != nil {
483+ t.Fatal(err)
484+ }
485+ // A resize sends a complete constraint batch, formats included.
486+ for _, want := range [][]uint32{{0, 5, 4}, {1, uint32(wayland.ShmFormatXrgb8888)}, {1, uint32(wayland.ShmFormatArgb8888)}, {4}} {
487+ got := <-p.events
488+ if !reflect.DeepEqual(got, want) {
489+ t.Fatalf("resize event %v != %v", got, want)
490+ }
491+ }
492+ // Destroying the session leaves its pending frame alive: it still gets ready.
493+ requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestDestroy) // one live frame per session
494+ buf2, frame2 := c.AllocateID(), c.AllocateID()
495+ registerProtocol(t, c, buf2)
496+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf2, int32(0), int32(5), int32(4), int32(20), uint32(wayland.ShmFormatXrgb8888))
497+ fp2 := &captureDetails{events: make(chan []uint32, 16)}
498+ fp2.SetID(frame2)
499+ c.Context().Register(fp2)
500+ requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame2)
501+ requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf2)
502+ requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestCapture)
503+ if err := c.Roundtrip(); err != nil {
504+ t.Fatal(err)
505+ }
506+ select {
507+ case req = <-requests:
508+ case <-time.After(2 * time.Second):
509+ t.Fatal("no capture request after resize")
510+ }
511+ if err := req.Dst.File.Close(); err != nil {
512+ t.Fatal(err)
513+ }
514+ requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestDestroy)
515+ if err := c.Roundtrip(); err != nil {
516+ t.Fatal(err)
517+ }
518+ replies <- ports.CaptureDone{ID: req.ID, Time: time.Unix(now.Sec, now.Nsec)}
519+ if !s.display.Do(func() {}) {
520+ t.Fatal("display stopped")
521+ }
522+ if err := c.Roundtrip(); err != nil {
523+ t.Fatal(err)
524+ }
525+ for _, want := range [][]uint32{{0, 0, 0}, {1, 0, 0, 5, 4}, {2, 0, uint32(now.Sec), uint32(now.Nsec)}, {3}} {
526+ select {
527+ case got := <-fp2.events:
528+ if !reflect.DeepEqual(got, want) {
529+ t.Fatalf("frame after session destroy: %v != %v", got, want)
530+ }
531+ case <-time.After(2 * time.Second):
532+ t.Fatalf("frame after session destroy: missing %v", want)
533+ }
534+ }
535+}
536+
537+// The remaining stop check needs a live session.
538+func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) {
539+ dir := t.TempDir()
540+ commands := make(chan ports.ClientCommand, 4)
541+ initial := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 4, Height: 4}, Width: 4, Height: 4, Scale: 1}
542+ s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{initial}}, Channels{Captures: make(chan ports.CaptureRequest, 1), Captured: make(chan ports.CaptureDone, 1), Commands: commands}, logging.For(context.Background(), "wayland"))
543+ if err != nil {
544+ t.Fatal(err)
545+ }
546+ ctx, cancel := context.WithCancel(context.Background())
547+ done := make(chan error, 1)
548+ go func() { done <- s.Run(ctx) }()
549+ defer func() {
550+ cancel()
551+ if err := <-done; err != nil {
552+ t.Error(err)
553+ }
554+ }()
555+ c := protocolClient(t, s, dir)
556+ g, ok := c.Registry().FindGlobal("wl_output")
557+ if !ok {
558+ t.Fatal("output missing")
559+ }
560+ out, err := c.Registry().BindID(g.Name, g.Interface, 4)
561+ if err != nil {
562+ t.Fatal(err)
563+ }
564+ registerProtocol(t, c, out)
565+ srcMgr := bindProtocol(t, c, "ext_output_image_capture_source_manager_v1")
566+ mgr := bindProtocol(t, c, "ext_image_copy_capture_manager_v1")
567+ src, session := c.AllocateID(), c.AllocateID()
568+ registerProtocol(t, c, src)
569+ p := &captureDetails{events: make(chan []uint32, 16)}
570+ p.SetID(session)
571+ c.Context().Register(p)
572+ requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out)
573+ requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0))
574+ if err := c.Roundtrip(); err != nil {
575+ t.Fatal(err)
576+ }
577+ for range 4 { // initial constraint batch
578+ <-p.events
579+ }
580+ commands <- ports.SetOutputs{}
581+ if !s.display.Do(func() {}) {
582+ t.Fatal("display stopped")
583+ }
584+ if err := c.Roundtrip(); err != nil {
585+ t.Fatal(err)
586+ }
587+ if got := <-p.events; !reflect.DeepEqual(got, []uint32{5}) {
588+ t.Fatalf("stopped: %v", got)
589+ }
590+}
591+
592+func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) {
593+ for _, destroy := range []bool{false, true} {
594+ t.Run(fmt.Sprint("destroy=", destroy), func(t *testing.T) {
595+ dir := t.TempDir()
596+ requests := make(chan ports.CaptureRequest, 1)
597+ replies := make(chan ports.CaptureDone, 1)
598+ commands := make(chan ports.ClientCommand, 2)
599+ initial := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}
600+ s, err := New(Options{RuntimeDir: dir, Outputs: ports.Layout{initial}}, Channels{Captures: requests, Captured: replies, Commands: commands}, logging.For(context.Background(), "wayland"))
601+ if err != nil {
602+ t.Fatal(err)
603+ }
604+ ctx, cancel := context.WithCancel(context.Background())
605+ done := make(chan error, 1)
606+ go func() { done <- s.Run(ctx) }()
607+ defer func() {
608+ cancel()
609+ if err := <-done; err != nil {
610+ t.Error(err)
611+ }
612+ }()
613+ c := protocolClient(t, s, dir)
614+ g, ok := c.Registry().FindGlobal("wl_output")
615+ if !ok {
616+ t.Fatal("output missing")
617+ }
618+ out, err := c.Registry().BindID(g.Name, g.Interface, 4)
619+ if err != nil {
620+ t.Fatal(err)
621+ }
622+ registerProtocol(t, c, out)
623+ manager := bindVersion(t, c, "zwlr_screencopy_manager_v1", 3)
624+ frame := c.AllocateID()
625+ events := &captureEvents{events: make(chan uint16, 8)}
626+ events.SetID(frame)
627+ c.Context().Register(events)
628+ requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), out)
629+ if err := c.Roundtrip(); err != nil {
630+ t.Fatal(err)
631+ }
632+ if got := <-events.events; got != uint16(wlr.ZwlrScreencopyFrameV1EventBuffer) {
633+ t.Fatal(got)
634+ }
635+ if got := <-events.events; got != uint16(wlr.ZwlrScreencopyFrameV1EventBufferDone) {
636+ t.Fatal(got)
637+ }
638+ if !destroy {
639+ commands <- ports.SetOutputs{}
640+ if !s.display.Do(func() {}) {
641+ t.Fatal("display stopped")
642+ }
643+ shm, buf, fd := captureSmallBuffer(t, c)
644+ defer unix.Close(fd)
645+ _ = shm
646+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
647+ if err := c.Roundtrip(); err != nil {
648+ t.Fatal(err)
649+ }
650+ if got := <-events.events; got != uint16(wlr.ZwlrScreencopyFrameV1EventFailed) {
651+ t.Fatal(got)
652+ }
653+ return
654+ }
655+ _, buf, fd := captureSmallBuffer(t, c)
656+ defer unix.Close(fd)
657+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf)
658+ if err := c.Roundtrip(); err != nil {
659+ t.Fatal(err)
660+ }
661+ var req ports.CaptureRequest
662+ select {
663+ case req = <-requests:
664+ case <-time.After(2 * time.Second):
665+ t.Fatal("no request")
666+ }
667+ requestProtocol(t, c, frame, wlr.ZwlrScreencopyFrameV1RequestDestroy)
668+ if err := c.Roundtrip(); err != nil {
669+ t.Fatal(err)
670+ }
671+ if !s.display.Do(func() {
672+ if _, ok := s.captureReplies[req.ID]; ok {
673+ t.Error("reply retained after frame destroy")
674+ }
675+ }) {
676+ t.Fatal("display stopped")
677+ }
678+ if err := req.Dst.File.Close(); err != nil {
679+ t.Fatal(err)
680+ }
681+ if _, err := req.Dst.File.Stat(); err == nil {
682+ t.Fatal("output descriptor open")
683+ }
684+ replies <- ports.CaptureDone{ID: req.ID}
685+ if !s.display.Do(func() {}) {
686+ t.Fatal("display stopped")
687+ }
688+ if err := c.Roundtrip(); err != nil {
689+ t.Fatal(err)
690+ }
691+ })
692+ }
693+}
694+
695+func captureSmallBuffer(t *testing.T, c *wlturbo.Display) (uint32, uint32, int) {
696+ t.Helper()
697+ shm := bindProtocol(t, c, "wl_shm")
698+ registerProtocol(t, c, shm)
699+ fd, err := unix.MemfdCreate("small-capture", 0)
700+ if err != nil {
701+ t.Fatal(err)
702+ }
703+ if err := unix.Ftruncate(fd, 16); err != nil {
704+ t.Fatal(err)
705+ }
706+ pool, buf := c.AllocateID(), c.AllocateID()
707+ registerProtocol(t, c, pool)
708+ registerProtocol(t, c, buf)
709+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil {
710+ t.Fatal(err)
711+ }
712+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888))
713+ return shm, buf, fd
714+}
M internal/adapters/wayland/server.go +18 -8
@@ -45,18 +45,24 @@ type Channels struct {
4545 // Presented paces frame callbacks on the outputs' page flips; outputs
4646 // that do not flip (idle, headless) are paced at their refresh rate.
4747 Presented <-chan ports.OutputPresented
48+ Captures chan<- ports.CaptureRequest
49+ Captured <-chan ports.CaptureDone
4850 // OutputFormats are the outputs' direct scanout formats, offered in
4951 // dmabuf feedback to fullscreen surfaces.
5052 OutputFormats <-chan ports.OutputFormats
5153 }
5254 type Server struct {
53- display *server.Display
54- env procEnv
55- slotsPending bool // core waits for a slot window
56- name string
57- cleanup func()
58- log zerowrap.Logger
59- channels Channels
55+ nextCapture uint64
56+ captureReplies map[uint64]func(ports.CaptureDone)
57+ captureSources map[*server.Resource]*output
58+ captureSessions map[*captureSession]struct{}
59+ display *server.Display
60+ env procEnv
61+ slotsPending bool // core waits for a slot window
62+ name string
63+ cleanup func()
64+ log zerowrap.Logger
65+ channels Channels
6066 // awaiting holds frame callbacks by output name, due at its next
6167 // frame (frameDue, or its page flip).
6268 awaiting map[string][]*wayland.Callback
@@ -191,6 +197,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
191197 return nil, err
192198 }
193199 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
200+ s.captureReplies = map[uint64]func(ports.CaptureDone){}
201+ s.captureSources = map[*server.Resource]*output{}
202+ s.captureSessions = map[*captureSession]struct{}{}
194203 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
195204 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}
196205 s.flips = map[string]ports.FlipInfo{}
@@ -254,7 +263,7 @@ func (s *Server) Run(ctx context.Context) error {
254263 s.started = time.Now()
255264 s.ctx = ctx
256265 var wg sync.WaitGroup
257- wg.Add(6)
266+ wg.Add(7)
258267 go func() { defer wg.Done(); s.forward(ctx) }()
259268 go func() { defer wg.Done(); s.forwardCursors(ctx) }()
260269 go func() { defer wg.Done(); s.forwardContents(ctx) }()
@@ -292,6 +301,7 @@ func (s *Server) Run(ctx context.Context) error {
292301 }()
293302 go func() { defer wg.Done(); s.pace(ctx) }()
294303 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()
304+ go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }()
295305 if s.syncWait != nil {
296306 wg.Add(1)
297307 go func() { defer wg.Done(); s.syncWait.run(ctx) }()
@@ -45,18 +45,24 @@ type Channels struct {
45 // Presented paces frame callbacks on the outputs' page flips; outputs45 // Presented paces frame callbacks on the outputs' page flips; outputs
46 // that do not flip (idle, headless) are paced at their refresh rate.46 // that do not flip (idle, headless) are paced at their refresh rate.
47 Presented <-chan ports.OutputPresented47 Presented <-chan ports.OutputPresented
48+ Captures chan<- ports.CaptureRequest
49+ Captured <-chan ports.CaptureDone
48 // OutputFormats are the outputs' direct scanout formats, offered in50 // OutputFormats are the outputs' direct scanout formats, offered in
49 // dmabuf feedback to fullscreen surfaces.51 // dmabuf feedback to fullscreen surfaces.
50 OutputFormats <-chan ports.OutputFormats52 OutputFormats <-chan ports.OutputFormats
51 }53 }
52 type Server struct {54 type Server struct {
53- display *server.Display55+ nextCapture uint64
54- env procEnv56+ captureReplies map[uint64]func(ports.CaptureDone)
55- slotsPending bool // core waits for a slot window57+ captureSources map[*server.Resource]*output
56- name string58+ captureSessions map[*captureSession]struct{}
57- cleanup func()59+ display *server.Display
58- log zerowrap.Logger60+ env procEnv
59- channels Channels61+ slotsPending bool // core waits for a slot window
62+ name string
63+ cleanup func()
64+ log zerowrap.Logger
65+ channels Channels
60 // awaiting holds frame callbacks by output name, due at its next66 // awaiting holds frame callbacks by output name, due at its next
61 // frame (frameDue, or its page flip).67 // frame (frameDue, or its page flip).
62 awaiting map[string][]*wayland.Callback68 awaiting map[string][]*wayland.Callback
@@ -191,6 +197,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
191 return nil, err197 return nil, err
192 }198 }
193 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}199 s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
200+ s.captureReplies = map[uint64]func(ports.CaptureDone){}
201+ s.captureSources = map[*server.Resource]*output{}
202+ s.captureSessions = map[*captureSession]struct{}{}
194 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}203 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
195 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}204 s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{}
196 s.flips = map[string]ports.FlipInfo{}205 s.flips = map[string]ports.FlipInfo{}
@@ -254,7 +263,7 @@ func (s *Server) Run(ctx context.Context) error {
254 s.started = time.Now()263 s.started = time.Now()
255 s.ctx = ctx264 s.ctx = ctx
256 var wg sync.WaitGroup265 var wg sync.WaitGroup
257- wg.Add(6)266+ wg.Add(7)
258 go func() { defer wg.Done(); s.forward(ctx) }()267 go func() { defer wg.Done(); s.forward(ctx) }()
259 go func() { defer wg.Done(); s.forwardCursors(ctx) }()268 go func() { defer wg.Done(); s.forwardCursors(ctx) }()
260 go func() { defer wg.Done(); s.forwardContents(ctx) }()269 go func() { defer wg.Done(); s.forwardContents(ctx) }()
@@ -292,6 +301,7 @@ func (s *Server) Run(ctx context.Context) error {
292 }()301 }()
293 go func() { defer wg.Done(); s.pace(ctx) }()302 go func() { defer wg.Done(); s.pace(ctx) }()
294 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()303 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()
304+ go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }()
295 if s.syncWait != nil {305 if s.syncWait != nil {
296 wg.Add(1)306 wg.Add(1)
297 go func() { defer wg.Done(); s.syncWait.run(ctx) }()307 go func() { defer wg.Done(); s.syncWait.run(ctx) }()
M internal/app/app.go +7 -3
@@ -88,6 +88,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
8888 contents := make(chan ports.SurfaceContent, 64)
8989 cursorChanges := make(chan ports.CursorChange, 1)
9090 presented := make(chan ports.OutputPresented, 64)
91+ captures := make(chan ports.CaptureRequest, 32)
92+ captured := make(chan ports.CaptureDone, 64)
9193 outputFormats := make(chan ports.OutputFormats, 8)
9294 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
9395 c, err := core.New(opts.Config, ch)
@@ -103,7 +105,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
103105 // Every output renders on the same GPU: its formats are the clients'.
104106 dmabuf := vulkan.Probe()
105107 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
106- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, OutputFormats: outputFormats}, logging.For(ctx, "wayland"))
108+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats}, logging.For(ctx, "wayland"))
107109 if err != nil {
108110 km.Close()
109111 return err
@@ -222,11 +224,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
222224 w.Sampled = dmabuf.Formats
223225 return w
224226 }
225- return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, outputFormats, curs, newRenderer, logging.For(ctx, "drm"))
227+ return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, curs, newRenderer, logging.For(ctx, "drm"))
226228 })
227229 return
228230 }
229- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "render"))
231+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, curs, newRenderer, logging.For(ctx, "render"))
230232 }()
231233
232234 if hw != nil {
@@ -244,6 +246,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
244246 }
245247 cancel()
246248 workers.Wait()
249+ // The wayland server has stopped: no request can be queued any more.
250+ drainCaptures(ctx, captures, nil)
247251 // A keymap the input goroutine never took is still ours to free.
248252 select {
249253 case km := <-keymaps:
@@ -88,6 +88,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
88 contents := make(chan ports.SurfaceContent, 64)88 contents := make(chan ports.SurfaceContent, 64)
89 cursorChanges := make(chan ports.CursorChange, 1)89 cursorChanges := make(chan ports.CursorChange, 1)
90 presented := make(chan ports.OutputPresented, 64)90 presented := make(chan ports.OutputPresented, 64)
91+ captures := make(chan ports.CaptureRequest, 32)
92+ captured := make(chan ports.CaptureDone, 64)
91 outputFormats := make(chan ports.OutputFormats, 8)93 outputFormats := make(chan ports.OutputFormats, 8)
92 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}94 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
93 c, err := core.New(opts.Config, ch)95 c, err := core.New(opts.Config, ch)
@@ -103,7 +105,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
103 // Every output renders on the same GPU: its formats are the clients'.105 // Every output renders on the same GPU: its formats are the clients'.
104 dmabuf := vulkan.Probe()106 dmabuf := vulkan.Probe()
105 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")107 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
106- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, OutputFormats: outputFormats}, logging.For(ctx, "wayland"))108+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats}, logging.For(ctx, "wayland"))
107 if err != nil {109 if err != nil {
108 km.Close()110 km.Close()
109 return err111 return err
@@ -222,11 +224,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
222 w.Sampled = dmabuf.Formats224 w.Sampled = dmabuf.Formats
223 return w225 return w
224 }226 }
225- return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, outputFormats, curs, newRenderer, logging.For(ctx, "drm"))227+ return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, curs, newRenderer, logging.For(ctx, "drm"))
226 })228 })
227 return229 return
228 }230 }
229- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, curs, newRenderer, logging.For(ctx, "render"))231+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, curs, newRenderer, logging.For(ctx, "render"))
230 }()232 }()
231 233
232 if hw != nil {234 if hw != nil {
@@ -244,6 +246,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
244 }246 }
245 cancel()247 cancel()
246 workers.Wait()248 workers.Wait()
249+ // The wayland server has stopped: no request can be queued any more.
250+ drainCaptures(ctx, captures, nil)
247 // A keymap the input goroutine never took is still ours to free.251 // A keymap the input goroutine never took is still ours to free.
248 select {252 select {
249 case km := <-keymaps:253 case km := <-keymaps:
A internal/app/capture_test.go +80 -0
@@ -0,0 +1,80 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func TestCaptureUnknownOutputClosesFile(t *testing.T) {
13+ replies := make(chan ports.CaptureDone, 1)
14+ set := newOutputSet(context.Background(), replies)
15+ f, err := os.CreateTemp(t.TempDir(), "capture")
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ set.routeCapture(ports.CaptureRequest{ID: 9, Output: "missing", Dst: ports.SHMBuffer{File: f}})
20+ select {
21+ case result := <-replies:
22+ if result.ID != 9 || result.Err == nil {
23+ t.Fatal(result)
24+ }
25+ if _, err := f.Stat(); err == nil {
26+ t.Fatal("request descriptor still open")
27+ }
28+ case <-time.After(time.Second):
29+ t.Fatal("no reply")
30+ }
31+}
32+
33+func TestDrainCapturesClosesQueuedFDs(t *testing.T) {
34+ ctx, cancel := context.WithCancel(context.Background())
35+ cancel()
36+ pending := make(chan ports.CaptureRequest, 3)
37+ for i := 0; i < 3; i++ {
38+ f, err := os.CreateTemp(t.TempDir(), "queued")
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ pending <- ports.CaptureRequest{Dst: ports.SHMBuffer{File: f}}
43+ t.Cleanup(func() {
44+ if _, err := f.Stat(); err == nil {
45+ t.Error("queued fd not closed")
46+ }
47+ })
48+ }
49+ drainCaptures(ctx, pending, nil)
50+ if len(pending) != 0 {
51+ t.Fatal("requests still queued")
52+ }
53+}
54+
55+func TestOutputStopWithFullReplies(t *testing.T) {
56+ ctx, cancel := context.WithCancel(context.Background())
57+ replies := make(chan ports.CaptureDone, 1)
58+ replies <- ports.CaptureDone{}
59+ set := newOutputSet(ctx, replies)
60+ set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest) error {
61+ <-ctx.Done()
62+ return nil
63+ })
64+ f, err := os.CreateTemp(t.TempDir(), "queued")
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ set.routeCapture(ports.CaptureRequest{Output: "test", Dst: ports.SHMBuffer{File: f}})
69+ cancel()
70+ done := make(chan struct{})
71+ go func() { _ = set.wait(); close(done) }()
72+ select {
73+ case <-done:
74+ case <-time.After(2 * time.Second):
75+ t.Fatal("output teardown blocked on replies")
76+ }
77+ if _, err := f.Stat(); err == nil {
78+ t.Fatal("queued fd still open")
79+ }
80+}
@@ -0,0 +1,80 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func TestCaptureUnknownOutputClosesFile(t *testing.T) {
13+ replies := make(chan ports.CaptureDone, 1)
14+ set := newOutputSet(context.Background(), replies)
15+ f, err := os.CreateTemp(t.TempDir(), "capture")
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ set.routeCapture(ports.CaptureRequest{ID: 9, Output: "missing", Dst: ports.SHMBuffer{File: f}})
20+ select {
21+ case result := <-replies:
22+ if result.ID != 9 || result.Err == nil {
23+ t.Fatal(result)
24+ }
25+ if _, err := f.Stat(); err == nil {
26+ t.Fatal("request descriptor still open")
27+ }
28+ case <-time.After(time.Second):
29+ t.Fatal("no reply")
30+ }
31+}
32+
33+func TestDrainCapturesClosesQueuedFDs(t *testing.T) {
34+ ctx, cancel := context.WithCancel(context.Background())
35+ cancel()
36+ pending := make(chan ports.CaptureRequest, 3)
37+ for i := 0; i < 3; i++ {
38+ f, err := os.CreateTemp(t.TempDir(), "queued")
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ pending <- ports.CaptureRequest{Dst: ports.SHMBuffer{File: f}}
43+ t.Cleanup(func() {
44+ if _, err := f.Stat(); err == nil {
45+ t.Error("queued fd not closed")
46+ }
47+ })
48+ }
49+ drainCaptures(ctx, pending, nil)
50+ if len(pending) != 0 {
51+ t.Fatal("requests still queued")
52+ }
53+}
54+
55+func TestOutputStopWithFullReplies(t *testing.T) {
56+ ctx, cancel := context.WithCancel(context.Background())
57+ replies := make(chan ports.CaptureDone, 1)
58+ replies <- ports.CaptureDone{}
59+ set := newOutputSet(ctx, replies)
60+ set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest) error {
61+ <-ctx.Done()
62+ return nil
63+ })
64+ f, err := os.CreateTemp(t.TempDir(), "queued")
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ set.routeCapture(ports.CaptureRequest{Output: "test", Dst: ports.SHMBuffer{File: f}})
69+ cancel()
70+ done := make(chan struct{})
71+ go func() { _ = set.wait(); close(done) }()
72+ select {
73+ case <-done:
74+ case <-time.After(2 * time.Second):
75+ t.Fatal("output teardown blocked on replies")
76+ }
77+ if _, err := f.Stat(); err == nil {
78+ t.Fatal("queued fd still open")
79+ }
80+}
M internal/app/drm.go +6 -4
@@ -109,7 +109,7 @@ func (b *drmBackend) close() {
109109 // one flip reader per card, and a udev watcher that rescans connectors on
110110 // hotplug. Core learns about outputs through events. It returns when ctx
111111 // ends, after every output is closed.
112-func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, formats chan<- ports.OutputFormats, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
112+func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
113113 ctx, cancel := context.WithCancel(ctx)
114114 defer cancel()
115115 var readers sync.WaitGroup
@@ -129,7 +129,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
129129 log.Warn().Err(err).Msg("hotplug watch disabled")
130130 }
131131 }()
132- set := newOutputSet()
132+ set := newOutputSet(ctx, captured)
133133 cards := map[string]*drmCard{}
134134 send := func(ev ports.OutputEvent) {
135135 select {
@@ -180,11 +180,11 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
180180 curs.set(name, cur)
181181 }
182182 active := b.seat.Subscribe()
183- set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {
183+ 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 {
184184 defer b.seat.Unsubscribe(active)
185185 return safe("output "+name, func() error {
186186 defer o.Close()
187- return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, presented)
187+ return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, presented, cap, captured)
188188 })
189189 })
190190 send(ports.OutputAdded{Info: o.Info()})
@@ -209,6 +209,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
209209 set.scenes(s)
210210 case c := <-contents:
211211 set.content(c)
212+ case q := <-captures:
213+ set.routeCapture(q)
212214 case c := <-cursorChanges:
213215 set.setCursor(c)
214216 case name := <-set.stopped:
@@ -109,7 +109,7 @@ func (b *drmBackend) close() {
109 // one flip reader per card, and a udev watcher that rescans connectors on109 // one flip reader per card, and a udev watcher that rescans connectors on
110 // hotplug. Core learns about outputs through events. It returns when ctx110 // hotplug. Core learns about outputs through events. It returns when ctx
111 // ends, after every output is closed.111 // ends, after every output is closed.
112-func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, formats chan<- ports.OutputFormats, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {112+func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
113 ctx, cancel := context.WithCancel(ctx)113 ctx, cancel := context.WithCancel(ctx)
114 defer cancel()114 defer cancel()
115 var readers sync.WaitGroup115 var readers sync.WaitGroup
@@ -129,7 +129,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
129 log.Warn().Err(err).Msg("hotplug watch disabled")129 log.Warn().Err(err).Msg("hotplug watch disabled")
130 }130 }
131 }()131 }()
132- set := newOutputSet()132+ set := newOutputSet(ctx, captured)
133 cards := map[string]*drmCard{}133 cards := map[string]*drmCard{}
134 send := func(ev ports.OutputEvent) {134 send := func(ev ports.OutputEvent) {
135 select {135 select {
@@ -180,11 +180,11 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
180 curs.set(name, cur)180 curs.set(name, cur)
181 }181 }
182 active := b.seat.Subscribe()182 active := b.seat.Subscribe()
183- set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {183+ 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 {
184 defer b.seat.Unsubscribe(active)184 defer b.seat.Unsubscribe(active)
185 return safe("output "+name, func() error {185 return safe("output "+name, func() error {
186 defer o.Close()186 defer o.Close()
187- return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, presented)187+ return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, presented, cap, captured)
188 })188 })
189 })189 })
190 send(ports.OutputAdded{Info: o.Info()})190 send(ports.OutputAdded{Info: o.Info()})
@@ -209,6 +209,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
209 set.scenes(s)209 set.scenes(s)
210 case c := <-contents:210 case c := <-contents:
211 set.content(c)211 set.content(c)
212+ case q := <-captures:
213+ set.routeCapture(q)
212 case c := <-cursorChanges:214 case c := <-cursorChanges:
213 set.setCursor(c)215 set.setCursor(c)
214 case name := <-set.stopped:216 case name := <-set.stopped:
M internal/app/headless.go +9 -6
@@ -13,22 +13,23 @@ import (
1313
1414 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
1515 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17- set := newOutputSet()
16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17+ set := newOutputSet(ctx, captured)
1818 for i, size := range sizes {
1919 name := fmt.Sprintf("HEADLESS-%d", i+1)
2020 dir := shots
2121 if dir != "" && len(sizes) > 1 {
2222 dir = filepath.Join(shots, name)
2323 if err := os.MkdirAll(dir, 0o755); err != nil {
24- return err
24+ // Outputs already started stop and release their captures.
25+ return joinErr(err, set.wait())
2526 }
2627 }
2728 cur := &headless.Cursor{}
2829 curs.set(name, cur)
29- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented}
30- set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {
31- return headless.Run(octx, opts, sc, cc, cu)
30+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented, Captured: captured}
31+ 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 {
32+ return headless.Run(octx, opts, sc, cc, cu, cap)
3233 })
3334 select {
3435 case events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
@@ -46,6 +47,8 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
4647 set.scenes(s)
4748 case c := <-contents:
4849 set.content(c)
50+ case q := <-captures:
51+ set.routeCapture(q)
4952 case c := <-cursorChanges:
5053 set.setCursor(c)
5154 case name := <-set.stopped:
@@ -13,22 +13,23 @@ import (
13 13
14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
15 // With several outputs, screenshots go to a subdirectory per output.15 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17- set := newOutputSet()17+ set := newOutputSet(ctx, captured)
18 for i, size := range sizes {18 for i, size := range sizes {
19 name := fmt.Sprintf("HEADLESS-%d", i+1)19 name := fmt.Sprintf("HEADLESS-%d", i+1)
20 dir := shots20 dir := shots
21 if dir != "" && len(sizes) > 1 {21 if dir != "" && len(sizes) > 1 {
22 dir = filepath.Join(shots, name)22 dir = filepath.Join(shots, name)
23 if err := os.MkdirAll(dir, 0o755); err != nil {23 if err := os.MkdirAll(dir, 0o755); err != nil {
24- return err24+ // Outputs already started stop and release their captures.
25+ return joinErr(err, set.wait())
25 }26 }
26 }27 }
27 cur := &headless.Cursor{}28 cur := &headless.Cursor{}
28 curs.set(name, cur)29 curs.set(name, cur)
29- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented}30+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer, Name: name, Presented: presented, Captured: captured}
30- set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {31+ 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 {
31- return headless.Run(octx, opts, sc, cc, cu)32+ return headless.Run(octx, opts, sc, cc, cu, cap)
32 })33 })
33 select {34 select {
34 case events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:35 case events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
@@ -46,6 +47,8 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
46 set.scenes(s)47 set.scenes(s)
47 case c := <-contents:48 case c := <-contents:
48 set.content(c)49 set.content(c)
50+ case q := <-captures:
51+ set.routeCapture(q)
49 case c := <-cursorChanges:52 case c := <-cursorChanges:
50 set.setCursor(c)53 set.setCursor(c)
51 case name := <-set.stopped:54 case name := <-set.stopped:
M internal/app/outputs.go +63 -10
@@ -2,14 +2,17 @@ package app
22
33 import (
44 "context"
5+ "fmt"
56 "sync"
67
8+ "github.com/bnema/neferwl/internal/adapters/capture"
9+
710 "github.com/bnema/neferwl/internal/ports"
811 )
912
1013 // outputRun drives one output until ctx ends: it renders the scenes and
1114 // surface contents it receives, with the cursor the client asks for.
12-type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error
15+type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest) error
1316
1417 // outputSet owns the running outputs and feeds them. Core sends one scene
1518 // per output; each goes to its output, latest first. Surface contents go to
@@ -17,34 +20,38 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c
1720 // each window is kept so a new output starts with every window drawn.
1821 // Only the goroutine running loop touches the set.
1922 type outputSet struct {
20- outs map[string]*runningOutput
21- latest map[ports.WindowID]ports.SurfaceContent
22- cursor ports.CursorChange
23- stopped chan string
24- quit chan struct{} // closed by wait: nobody reads stopped any more
23+ outs map[string]*runningOutput
24+ latest map[ports.WindowID]ports.SurfaceContent
25+ cursor ports.CursorChange
26+ stopped chan string
27+ captured chan<- ports.CaptureDone
28+ ctx context.Context
29+ quit chan struct{} // closed by wait: nobody reads stopped any more
2530 }
2631
2732 type runningOutput struct {
2833 scenes chan ports.Scene
2934 contents chan ports.SurfaceContent
3035 cursor chan ports.CursorChange
36+ captures chan ports.CaptureRequest
3137 ctx context.Context
3238 stop context.CancelFunc
3339 err error
3440 done chan struct{}
3541 }
3642
37-func newOutputSet() *outputSet {
38- return &outputSet{outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan string), quit: make(chan struct{})}
43+func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet {
44+ return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan string), quit: make(chan struct{})}
3945 }
4046
4147 // start runs an output in its own goroutine and replays the window contents.
4248 func (s *outputSet) start(ctx context.Context, name string, run outputRun) {
4349 octx, stop := context.WithCancel(ctx)
44- r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), ctx: octx, stop: stop, done: make(chan struct{})}
50+ r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})}
4551 s.outs[name] = r
4652 go func() {
47- r.err = run(octx, r.scenes, r.contents, r.cursor)
53+ r.err = run(octx, r.scenes, r.contents, r.cursor, r.captures)
54+ stop()
4855 close(r.done)
4956 select {
5057 case s.stopped <- name:
@@ -110,6 +117,7 @@ func (s *outputSet) finish(name string) error {
110117 }
111118 <-r.done
112119 r.stop()
120+ s.failQueued(r)
113121 delete(s.outs, name)
114122 return r.err
115123 }
@@ -121,6 +129,7 @@ func (s *outputSet) wait() error {
121129 for name, r := range s.outs {
122130 r.stop()
123131 <-r.done
132+ s.failQueued(r)
124133 if first == nil && r.err != nil {
125134 first = r.err
126135 }
@@ -177,3 +186,47 @@ func (c *cursors) move(output string, x, y float64) {
177186 cur.Move(x, y)
178187 }
179188 }
189+
190+// routeCapture transfers the request to its output or closes it on failure.
191+func (s *outputSet) routeCapture(req ports.CaptureRequest) {
192+ r := s.outs[req.Output]
193+ if r == nil {
194+ capture.Fail(s.ctx, req, fmt.Errorf("output %q unavailable", req.Output), s.captured)
195+ return
196+ }
197+ select {
198+ case r.captures <- req:
199+ case <-r.done:
200+ r.stop()
201+ capture.Fail(r.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)
202+ case <-r.ctx.Done():
203+ capture.Fail(r.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)
204+ }
205+}
206+
207+func (s *outputSet) failQueued(r *runningOutput) {
208+ for {
209+ select {
210+ case req := <-r.captures:
211+ capture.Fail(r.ctx, req, fmt.Errorf("output stopped"), s.captured)
212+ default:
213+ return
214+ }
215+ }
216+}
217+
218+// drainCaptures releases requests left in the app queue. Call it once the
219+// producer (the wayland server) has stopped, so none can arrive after it.
220+func drainCaptures(ctx context.Context, incoming <-chan ports.CaptureRequest, replies chan<- ports.CaptureDone) {
221+ for {
222+ select {
223+ case q, ok := <-incoming:
224+ if !ok {
225+ return
226+ }
227+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), replies)
228+ default:
229+ return
230+ }
231+ }
232+}
@@ -2,14 +2,17 @@ package app
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "sync"6 "sync"
6 7
8+ "github.com/bnema/neferwl/internal/adapters/capture"
9+
7 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
8 )11 )
9 12
10 // outputRun drives one output until ctx ends: it renders the scenes and13 // outputRun drives one output until ctx ends: it renders the scenes and
11 // surface contents it receives, with the cursor the client asks for.14 // surface contents it receives, with the cursor the client asks for.
12-type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error15+type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest) error
13 16
14 // outputSet owns the running outputs and feeds them. Core sends one scene17 // outputSet owns the running outputs and feeds them. Core sends one scene
15 // per output; each goes to its output, latest first. Surface contents go to18 // per output; each goes to its output, latest first. Surface contents go to
@@ -17,34 +20,38 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c
17 // each window is kept so a new output starts with every window drawn.20 // each window is kept so a new output starts with every window drawn.
18 // Only the goroutine running loop touches the set.21 // Only the goroutine running loop touches the set.
19 type outputSet struct {22 type outputSet struct {
20- outs map[string]*runningOutput23+ outs map[string]*runningOutput
21- latest map[ports.WindowID]ports.SurfaceContent24+ latest map[ports.WindowID]ports.SurfaceContent
22- cursor ports.CursorChange25+ cursor ports.CursorChange
23- stopped chan string26+ stopped chan string
24- quit chan struct{} // closed by wait: nobody reads stopped any more27+ captured chan<- ports.CaptureDone
28+ ctx context.Context
29+ quit chan struct{} // closed by wait: nobody reads stopped any more
25 }30 }
26 31
27 type runningOutput struct {32 type runningOutput struct {
28 scenes chan ports.Scene33 scenes chan ports.Scene
29 contents chan ports.SurfaceContent34 contents chan ports.SurfaceContent
30 cursor chan ports.CursorChange35 cursor chan ports.CursorChange
36+ captures chan ports.CaptureRequest
31 ctx context.Context37 ctx context.Context
32 stop context.CancelFunc38 stop context.CancelFunc
33 err error39 err error
34 done chan struct{}40 done chan struct{}
35 }41 }
36 42
37-func newOutputSet() *outputSet {43+func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet {
38- return &outputSet{outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan string), quit: make(chan struct{})}44+ return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan string), quit: make(chan struct{})}
39 }45 }
40 46
41 // start runs an output in its own goroutine and replays the window contents.47 // start runs an output in its own goroutine and replays the window contents.
42 func (s *outputSet) start(ctx context.Context, name string, run outputRun) {48 func (s *outputSet) start(ctx context.Context, name string, run outputRun) {
43 octx, stop := context.WithCancel(ctx)49 octx, stop := context.WithCancel(ctx)
44- r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), ctx: octx, stop: stop, done: make(chan struct{})}50+ r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})}
45 s.outs[name] = r51 s.outs[name] = r
46 go func() {52 go func() {
47- r.err = run(octx, r.scenes, r.contents, r.cursor)53+ r.err = run(octx, r.scenes, r.contents, r.cursor, r.captures)
54+ stop()
48 close(r.done)55 close(r.done)
49 select {56 select {
50 case s.stopped <- name:57 case s.stopped <- name:
@@ -110,6 +117,7 @@ func (s *outputSet) finish(name string) error {
110 }117 }
111 <-r.done118 <-r.done
112 r.stop()119 r.stop()
120+ s.failQueued(r)
113 delete(s.outs, name)121 delete(s.outs, name)
114 return r.err122 return r.err
115 }123 }
@@ -121,6 +129,7 @@ func (s *outputSet) wait() error {
121 for name, r := range s.outs {129 for name, r := range s.outs {
122 r.stop()130 r.stop()
123 <-r.done131 <-r.done
132+ s.failQueued(r)
124 if first == nil && r.err != nil {133 if first == nil && r.err != nil {
125 first = r.err134 first = r.err
126 }135 }
@@ -177,3 +186,47 @@ func (c *cursors) move(output string, x, y float64) {
177 cur.Move(x, y)186 cur.Move(x, y)
178 }187 }
179 }188 }
189+
190+// routeCapture transfers the request to its output or closes it on failure.
191+func (s *outputSet) routeCapture(req ports.CaptureRequest) {
192+ r := s.outs[req.Output]
193+ if r == nil {
194+ capture.Fail(s.ctx, req, fmt.Errorf("output %q unavailable", req.Output), s.captured)
195+ return
196+ }
197+ select {
198+ case r.captures <- req:
199+ case <-r.done:
200+ r.stop()
201+ capture.Fail(r.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)
202+ case <-r.ctx.Done():
203+ capture.Fail(r.ctx, req, fmt.Errorf("output %q stopped", req.Output), s.captured)
204+ }
205+}
206+
207+func (s *outputSet) failQueued(r *runningOutput) {
208+ for {
209+ select {
210+ case req := <-r.captures:
211+ capture.Fail(r.ctx, req, fmt.Errorf("output stopped"), s.captured)
212+ default:
213+ return
214+ }
215+ }
216+}
217+
218+// drainCaptures releases requests left in the app queue. Call it once the
219+// producer (the wayland server) has stopped, so none can arrive after it.
220+func drainCaptures(ctx context.Context, incoming <-chan ports.CaptureRequest, replies chan<- ports.CaptureDone) {
221+ for {
222+ select {
223+ case q, ok := <-incoming:
224+ if !ok {
225+ return
226+ }
227+ capture.Fail(ctx, q, fmt.Errorf("output stopped"), replies)
228+ default:
229+ return
230+ }
231+ }
232+}
M internal/app/outputs_test.go +2 -2
@@ -12,12 +12,12 @@ import (
1212 func TestOutputSetCursor(t *testing.T) {
1313 ctx, cancel := context.WithCancel(context.Background())
1414 defer cancel()
15- set := newOutputSet()
15+ set := newOutputSet(context.Background(), nil)
1616 got := map[string]chan ports.CursorChange{}
1717 run := func(name string) outputRun {
1818 got[name] = make(chan ports.CursorChange, 8)
1919 out := got[name]
20- return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
20+ return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error {
2121 for {
2222 select {
2323 case <-ctx.Done():
@@ -12,12 +12,12 @@ import (
12 func TestOutputSetCursor(t *testing.T) {12 func TestOutputSetCursor(t *testing.T) {
13 ctx, cancel := context.WithCancel(context.Background())13 ctx, cancel := context.WithCancel(context.Background())
14 defer cancel()14 defer cancel()
15- set := newOutputSet()15+ set := newOutputSet(context.Background(), nil)
16 got := map[string]chan ports.CursorChange{}16 got := map[string]chan ports.CursorChange{}
17 run := func(name string) outputRun {17 run := func(name string) outputRun {
18 got[name] = make(chan ports.CursorChange, 8)18 got[name] = make(chan ports.CursorChange, 8)
19 out := got[name]19 out := got[name]
20- return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {20+ return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error {
21 for {21 for {
22 select {22 select {
23 case <-ctx.Done():23 case <-ctx.Done():
M internal/mocks/ports/devices_mock.go +63 -0
@@ -48,6 +48,69 @@ func (_m *MockRenderer) EXPECT() *MockRenderer_Expecter {
4848 return &MockRenderer_Expecter{mock: &_m.Mock}
4949 }
5050
51+// Capture provides a mock function for the type MockRenderer
52+func (_mock *MockRenderer) Capture(region image.Rectangle, dst []byte, stride int) error {
53+ ret := _mock.Called(region, dst, stride)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Capture")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(image.Rectangle, []byte, int) error); ok {
61+ r0 = returnFunc(region, dst, stride)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// MockRenderer_Capture_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Capture'
69+type MockRenderer_Capture_Call struct {
70+ *mock.Call
71+}
72+
73+// Capture is a helper method to define mock.On call
74+// - region image.Rectangle
75+// - dst []byte
76+// - stride int
77+func (_e *MockRenderer_Expecter) Capture(region any, dst any, stride any) *MockRenderer_Capture_Call {
78+ return &MockRenderer_Capture_Call{Call: _e.mock.On("Capture", region, dst, stride)}
79+}
80+
81+func (_c *MockRenderer_Capture_Call) Run(run func(region image.Rectangle, dst []byte, stride int)) *MockRenderer_Capture_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 image.Rectangle
84+ if args[0] != nil {
85+ arg0 = args[0].(image.Rectangle)
86+ }
87+ var arg1 []byte
88+ if args[1] != nil {
89+ arg1 = args[1].([]byte)
90+ }
91+ var arg2 int
92+ if args[2] != nil {
93+ arg2 = args[2].(int)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ arg2,
99+ )
100+ })
101+ return _c
102+}
103+
104+func (_c *MockRenderer_Capture_Call) Return(err error) *MockRenderer_Capture_Call {
105+ _c.Call.Return(err)
106+ return _c
107+}
108+
109+func (_c *MockRenderer_Capture_Call) RunAndReturn(run func(region image.Rectangle, dst []byte, stride int) error) *MockRenderer_Capture_Call {
110+ _c.Call.Return(run)
111+ return _c
112+}
113+
51114 // Close provides a mock function for the type MockRenderer
52115 func (_mock *MockRenderer) Close() {
53116 _mock.Called()
@@ -48,6 +48,69 @@ func (_m *MockRenderer) EXPECT() *MockRenderer_Expecter {
48 return &MockRenderer_Expecter{mock: &_m.Mock}48 return &MockRenderer_Expecter{mock: &_m.Mock}
49 }49 }
50 50
51+// Capture provides a mock function for the type MockRenderer
52+func (_mock *MockRenderer) Capture(region image.Rectangle, dst []byte, stride int) error {
53+ ret := _mock.Called(region, dst, stride)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Capture")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(image.Rectangle, []byte, int) error); ok {
61+ r0 = returnFunc(region, dst, stride)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// MockRenderer_Capture_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Capture'
69+type MockRenderer_Capture_Call struct {
70+ *mock.Call
71+}
72+
73+// Capture is a helper method to define mock.On call
74+// - region image.Rectangle
75+// - dst []byte
76+// - stride int
77+func (_e *MockRenderer_Expecter) Capture(region any, dst any, stride any) *MockRenderer_Capture_Call {
78+ return &MockRenderer_Capture_Call{Call: _e.mock.On("Capture", region, dst, stride)}
79+}
80+
81+func (_c *MockRenderer_Capture_Call) Run(run func(region image.Rectangle, dst []byte, stride int)) *MockRenderer_Capture_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 image.Rectangle
84+ if args[0] != nil {
85+ arg0 = args[0].(image.Rectangle)
86+ }
87+ var arg1 []byte
88+ if args[1] != nil {
89+ arg1 = args[1].([]byte)
90+ }
91+ var arg2 int
92+ if args[2] != nil {
93+ arg2 = args[2].(int)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ arg2,
99+ )
100+ })
101+ return _c
102+}
103+
104+func (_c *MockRenderer_Capture_Call) Return(err error) *MockRenderer_Capture_Call {
105+ _c.Call.Return(err)
106+ return _c
107+}
108+
109+func (_c *MockRenderer_Capture_Call) RunAndReturn(run func(region image.Rectangle, dst []byte, stride int) error) *MockRenderer_Capture_Call {
110+ _c.Call.Return(run)
111+ return _c
112+}
113+
51 // Close provides a mock function for the type MockRenderer114 // Close provides a mock function for the type MockRenderer
52 func (_mock *MockRenderer) Close() {115 func (_mock *MockRenderer) Close() {
53 _mock.Called()116 _mock.Called()
M internal/ports/devices.go +2 -0
@@ -27,6 +27,8 @@ type Renderer interface {
2727 WriteCursor(i int, pixels []byte, w, h int) error
2828 // Pixels reads the last frame back (headless screenshots, tests).
2929 Pixels() *image.RGBA
30+ // Capture copies a region of the last rendered frame as opaque BGRA.
31+ Capture(region image.Rectangle, dst []byte, stride int) error
3032 Close()
3133 }
3234
@@ -27,6 +27,8 @@ type Renderer interface {
27 WriteCursor(i int, pixels []byte, w, h int) error27 WriteCursor(i int, pixels []byte, w, h int) error
28 // Pixels reads the last frame back (headless screenshots, tests).28 // Pixels reads the last frame back (headless screenshots, tests).
29 Pixels() *image.RGBA29 Pixels() *image.RGBA
30+ // Capture copies a region of the last rendered frame as opaque BGRA.
31+ Capture(region image.Rectangle, dst []byte, stride int) error
30 Close()32 Close()
31 }33 }
32 34
M internal/ports/messages.go +21 -0
@@ -1,10 +1,31 @@
11 package ports
22
33 import (
4+ "image"
45 "os"
56 "time"
67 )
78
9+// CaptureRequest transfers ownership of Dst.File to the output goroutine.
10+type CaptureRequest struct {
11+ ID uint64
12+ Output string
13+ Region image.Rectangle // clipped output buffer coordinates
14+ Cursor bool
15+ Dst SHMBuffer
16+ Width, Height, Stride int
17+ Format uint32
18+}
19+
20+// CaptureDone is attempted once after the output closes the destination.
21+// Time holds CLOCK_MONOTONIC seconds and nanoseconds since boot, not wall time.
22+type CaptureDone struct {
23+ ID uint64
24+ Output string
25+ Err error
26+ Time time.Time
27+}
28+
829 // ClientEvent carries wayland → core notifications.
930 type ClientEvent interface{ clientEvent() }
1031
@@ -1,10 +1,31 @@
1 package ports1 package ports
2 2
3 import (3 import (
4+ "image"
4 "os"5 "os"
5 "time"6 "time"
6 )7 )
7 8
9+// CaptureRequest transfers ownership of Dst.File to the output goroutine.
10+type CaptureRequest struct {
11+ ID uint64
12+ Output string
13+ Region image.Rectangle // clipped output buffer coordinates
14+ Cursor bool
15+ Dst SHMBuffer
16+ Width, Height, Stride int
17+ Format uint32
18+}
19+
20+// CaptureDone is attempted once after the output closes the destination.
21+// Time holds CLOCK_MONOTONIC seconds and nanoseconds since boot, not wall time.
22+type CaptureDone struct {
23+ ID uint64
24+ Output string
25+ Err error
26+ Time time.Time
27+}
28+
8 // ClientEvent carries wayland → core notifications.29 // ClientEvent carries wayland → core notifications.
9 type ClientEvent interface{ clientEvent() }30 type ClientEvent interface{ clientEvent() }
10 31

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 23

Tags 10

↑↓ moveenter openesc close