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 Unverified
B5690EEEBB952194The key is unknown
28 files · +1796 -54
M README.md +3 -1
| @@ -183,6 +183,8 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots | |||
| 183 | 183 | 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 | 188 | `/tmp/neferwl-shots/latest.png` shows the current frame. `--timeout 5s` stops NeferWL after 5 seconds. | |
| 187 | 189 | ||
| 188 | 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 | 195 | - `move <x> <y>` (output coordinates) | |
| 194 | 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 | 200 | ## Why not Rust? | |
| 199 | 201 | ||
| @@ -183,6 +183,8 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots | |||||
| 183 | WAYLAND_DISPLAY=<name from the log> foot | 183 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | 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 | |
| 8 | 8 | github.com/bnema/purego-vulkan v0.4.1 | |
| 9 | 9 | github.com/bnema/wlturbo v0.2.0 | |
| 10 | 10 | 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.4 | 6 | 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 |
| 8 | github.com/bnema/purego-vulkan v0.4.1 | 8 | github.com/bnema/purego-vulkan v0.4.1 | ||
| 9 | github.com/bnema/wlturbo v0.2.0 | 9 | github.com/bnema/wlturbo v0.2.0 | ||
| 10 | github.com/bnema/zerowrap v1.4.1 | 10 | 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) { | |||
| 171 | 171 | ctx, cancel := context.WithCancel(context.Background()) | |
| 172 | 172 | done := make(chan error, 1) | |
| 173 | 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 | 176 | count := func(flags uint32) int { | |
| 177 | 177 | commitMu.Lock() | |
| @@ -287,7 +287,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) { | |||
| 287 | 287 | ctx, cancel := context.WithCancel(context.Background()) | |
| 288 | 288 | done := make(chan error, 1) | |
| 289 | 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 | 292 | scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1} | |
| 293 | 293 | var frame commitRec | |
| @@ -370,7 +370,7 @@ func TestRunClosesFrameFences(t *testing.T) { | |||
| 370 | 370 | ctx, cancel := context.WithCancel(context.Background()) | |
| 371 | 371 | done := make(chan error, 1) | |
| 372 | 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 | 375 | count := func() int { | |
| 376 | 376 | 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 commitRec | 293 | 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 ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | "unsafe" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 15 | + | ||
| 14 | 16 | "github.com/bnema/neferwl/internal/adapters/syncfile" | |
| 15 | 17 | "github.com/bnema/neferwl/internal/ports" | |
| 16 | 18 | "github.com/bnema/zerowrap" | |
| @@ -836,7 +838,7 @@ func (o *Output) Close() { | |||
| 836 | 838 | ||
| 837 | 839 | // Run renders scenes and commits them until ctx ends. active reports seat | |
| 838 | 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 | 842 | r, err := newRenderer(o.Width(), o.Height()) | |
| 841 | 843 | if err != nil { | |
| 842 | 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 | 871 | var want ports.CursorChange | |
| 870 | 872 | cursorScale := -1.0 // not loaded yet | |
| 871 | 873 | 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 | 890 | stats := time.NewTicker(10 * time.Second) | |
| 873 | 891 | defer stats.Stop() | |
| 874 | 892 | // 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 | 947 | stateDirty = true | |
| 930 | 948 | case <-ctx.Done(): | |
| 931 | 949 | 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 | 957 | case on := <-active: | |
| 933 | 958 | // Modeset on every enable: a fast disable+enable can coalesce to one true. | |
| 934 | 959 | if on { | |
| @@ -1000,6 +1025,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1000 | 1025 | } | |
| 1001 | 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 | 1034 | if !enabled || o.pending { | |
| 1004 | 1035 | continue | |
| 1005 | 1036 | } | |
| @@ -1036,13 +1067,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1036 | 1067 | // shows is what the frame puts on screen: presentation feedback | |
| 1037 | 1068 | // of windows it does not draw is discarded, not presented. | |
| 1038 | 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 | 1076 | direct := false | |
| 1041 | 1077 | if fb != 0 { | |
| 1042 | 1078 | direct, err = o.commitScanout(fb, c, pendingFrame{shows: map[ports.WindowID]uint64{c.ID: seen[c.ID]}}) | |
| 1043 | 1079 | } | |
| 1044 | 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 | 1086 | r.UseTarget(o.back) | |
| 1047 | 1087 | done, rerr := r.Render(composed, surfaces) | |
| 1048 | 1088 | if rerr != nil { | |
| @@ -1060,6 +1100,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1060 | 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 | 1111 | // The overlay buffer is on screen like a scanned-out one: it | |
| 1064 | 1112 | // is reported shown, so it is not released under the plane. | |
| 1065 | 1113 | 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 seat | 839 | // 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.CursorChange | 871 | var want ports.CursorChange | ||
| 870 | cursorScale := -1.0 // not loaded yet | 872 | cursorScale := -1.0 // not loaded yet | ||
| 871 | frame := 0 | 873 | 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 while | 892 | // 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 = true | 947 | stateDirty = true | ||
| 930 | case <-ctx.Done(): | 948 | case <-ctx.Done(): | ||
| 931 | return nil | 949 | 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 | continue | 1035 | 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 feedback | 1067 | // 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 := false | 1076 | 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: it | 1111 | // 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, true | 1113 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "image" | |
| 5 | 6 | "os" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "sync/atomic" | |
| @@ -398,7 +399,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) { | |||
| 398 | 399 | ctx, cancel := context.WithCancel(context.Background()) | |
| 399 | 400 | done := make(chan error, 1) | |
| 400 | 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 | 404 | scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}} | |
| 404 | 405 | waitFor(t, func() bool { return frameCommits() == 1 }) | |
| @@ -447,3 +448,78 @@ func waitFor(t *testing.T, cond func() bool) { | |||
| 447 | 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 | + | } | |
| @@ -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) { | |||
| 47 | 47 | ctx, cancel := context.WithCancel(context.Background()) | |
| 48 | 48 | done := make(chan error, 1) | |
| 49 | 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 | 52 | on := ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1} | |
| 53 | 53 | 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 <- on | 53 | 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 { | |||
| 69 | 69 | ctx, cancel := context.WithCancel(context.Background()) | |
| 70 | 70 | done := make(chan error, 1) | |
| 71 | 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 | 74 | h.count = func() int { | |
| 75 | 75 | 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 ( | |||
| 11 | 11 | "path/filepath" | |
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 14 | 15 | "github.com/bnema/neferwl/internal/adapters/syncfile" | |
| 15 | 16 | "github.com/bnema/neferwl/internal/ports" | |
| 16 | 17 | "github.com/bnema/zerowrap" | |
| @@ -29,9 +30,10 @@ type Options struct { | |||
| 29 | 30 | // frame, so wayland can release client buffers (nil: no reports). | |
| 30 | 31 | Name string | |
| 31 | 32 | 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 | 37 | r, err := opts.NewRenderer(opts.Width, opts.Height) | |
| 36 | 38 | if err != nil { | |
| 37 | 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 | 43 | var scene ports.Scene | |
| 42 | 44 | haveScene, dirty := false, false | |
| 43 | 45 | 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 | 62 | var want ports.CursorChange | |
| 45 | 63 | cursorScale := -1.0 // not loaded yet | |
| 46 | 64 | seen := map[ports.WindowID]uint64{} | |
| @@ -66,6 +84,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 66 | 84 | select { | |
| 67 | 85 | case <-ctx.Done(): | |
| 68 | 86 | 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 | 94 | case <-retry: | |
| 70 | 95 | pending = opts.send(pending) | |
| 71 | 96 | continue | |
| @@ -105,6 +130,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 105 | 130 | 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 | 139 | if !haveScene || !dirty || scene.Off { | |
| 109 | 140 | // Contents not drawn are still read: report them. An output | |
| 110 | 141 | // turned off draws nothing. | |
| @@ -137,6 +168,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 137 | 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 | 178 | frame++ | |
| 141 | 179 | pending = opts.flipped(pending, seen, scene, surfaces) | |
| 142 | 180 | 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 string | 31 | Name string | ||
| 31 | Presented chan<- ports.OutputPresented | 32 | 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.Scene | 43 | var scene ports.Scene | ||
| 42 | haveScene, dirty := false, false | 44 | haveScene, dirty := false, false | ||
| 43 | frame := 0 | 45 | 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.CursorChange | 62 | var want ports.CursorChange | ||
| 45 | cursorScale := -1.0 // not loaded yet | 63 | 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 nil | 86 | 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 | continue | 96 | continue | ||
| @@ -105,6 +130,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 105 | break drain | 130 | 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 output | 140 | // 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) { | |||
| 76 | 76 | ctx, cancel := context.WithCancel(context.Background()) | |
| 77 | 77 | done := make(chan error, 1) | |
| 78 | 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 | 81 | waitFrames(t, f, 1) | |
| 82 | 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 | 115 | ctx, cancel := context.WithCancel(context.Background()) | |
| 116 | 116 | done := make(chan error, 1) | |
| 117 | 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 | 120 | waitFrames(t, f, 1) | |
| 121 | 121 | contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}} | |
| @@ -139,11 +139,11 @@ func TestErrors(t *testing.T) { | |||
| 139 | 139 | expected := errors.New("failure") | |
| 140 | 140 | scenes := make(chan ports.Scene, 1) | |
| 141 | 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 | 143 | t.Fatal(err) | |
| 144 | 144 | } | |
| 145 | 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 | 147 | t.Fatal(err) | |
| 148 | 148 | } | |
| 149 | 149 | } | |
| @@ -184,7 +184,7 @@ func TestRunPassesLayerContent(t *testing.T) { | |||
| 184 | 184 | ctx, cancel := context.WithCancel(context.Background()) | |
| 185 | 185 | done := make(chan error, 1) | |
| 186 | 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 | 189 | deadline := time.After(3 * time.Second) | |
| 190 | 190 | for { | |
| @@ -205,3 +205,46 @@ func TestRunPassesLayerContent(t *testing.T) { | |||
| 205 | 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 | + | } | |
| @@ -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 { | |||
| 274 | 274 | 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 | 302 | // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE. | |
| 278 | 303 | const ( | |
| 279 | 304 | queueFamilyIgnored = ^uint32(0) | |
| @@ -274,6 +274,31 @@ func (r *Renderer) Pixels() *image.RGBA { | |||||
| 274 | return out | 274 | 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 { | |||
| 23 | 23 | func() error { return registerSyncobj(d, s) }, | |
| 24 | 24 | func() error { return registerInhibit(d, s) }, | |
| 25 | 25 | func() error { return registerIdle(d, s) }, | |
| 26 | + | func() error { return registerCapture(d, s) }, | |
| 26 | 27 | func() error { return registerActivation(d, s) }, | |
| 27 | 28 | func() error { return registerVirtualKeyboard(d, s) }, | |
| 28 | 29 | 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) { | |||
| 147 | 147 | continue | |
| 148 | 148 | } | |
| 149 | 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 | 156 | s.log.Info().Str("output", o.name()).Msg("output removed") | |
| 151 | 157 | for _, l := range s.layers { | |
| 152 | 158 | if l.output == o { | |
| @@ -168,7 +174,15 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||
| 168 | 174 | continue | |
| 169 | 175 | } | |
| 170 | 176 | scaleChanged := o.place.Scale != p.Scale | |
| 177 | + | sizeChanged := o.place.Info.Width != p.Info.Width || o.place.Info.Height != p.Info.Height | |
| 171 | 178 | 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 | 186 | for _, r := range o.resources { | |
| 173 | 187 | o.sendAll(r) | |
| 174 | 188 | } | |
| @@ -147,6 +147,12 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||||
| 147 | continue | 147 | 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 | continue | 174 | continue | ||
| 169 | } | 175 | } | ||
| 170 | scaleChanged := o.place.Scale != p.Scale | 176 | 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 = p | 178 | 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 { | |||
| 45 | 45 | // Presented paces frame callbacks on the outputs' page flips; outputs | |
| 46 | 46 | // that do not flip (idle, headless) are paced at their refresh rate. | |
| 47 | 47 | Presented <-chan ports.OutputPresented | |
| 48 | + | Captures chan<- ports.CaptureRequest | |
| 49 | + | Captured <-chan ports.CaptureDone | |
| 48 | 50 | // OutputFormats are the outputs' direct scanout formats, offered in | |
| 49 | 51 | // dmabuf feedback to fullscreen surfaces. | |
| 50 | 52 | OutputFormats <-chan ports.OutputFormats | |
| 51 | 53 | } | |
| 52 | 54 | 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 | |
| 60 | 66 | // awaiting holds frame callbacks by output name, due at its next | |
| 61 | 67 | // frame (frameDue, or its page flip). | |
| 62 | 68 | awaiting map[string][]*wayland.Callback | |
| @@ -191,6 +197,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 191 | 197 | return nil, err | |
| 192 | 198 | } | |
| 193 | 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 | 203 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | |
| 195 | 204 | s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{} | |
| 196 | 205 | s.flips = map[string]ports.FlipInfo{} | |
| @@ -254,7 +263,7 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 254 | 263 | s.started = time.Now() | |
| 255 | 264 | s.ctx = ctx | |
| 256 | 265 | var wg sync.WaitGroup | |
| 257 | - | wg.Add(6) | |
| 266 | + | wg.Add(7) | |
| 258 | 267 | go func() { defer wg.Done(); s.forward(ctx) }() | |
| 259 | 268 | go func() { defer wg.Done(); s.forwardCursors(ctx) }() | |
| 260 | 269 | go func() { defer wg.Done(); s.forwardContents(ctx) }() | |
| @@ -292,6 +301,7 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 292 | 301 | }() | |
| 293 | 302 | go func() { defer wg.Done(); s.pace(ctx) }() | |
| 294 | 303 | go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }() | |
| 304 | + | go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }() | |
| 295 | 305 | if s.syncWait != nil { | |
| 296 | 306 | wg.Add(1) | |
| 297 | 307 | 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; outputs | 45 | // 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.OutputPresented | 47 | Presented <-chan ports.OutputPresented | ||
| 48 | + | Captures chan<- ports.CaptureRequest | |||
| 49 | + | Captured <-chan ports.CaptureDone | |||
| 48 | // OutputFormats are the outputs' direct scanout formats, offered in | 50 | // 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.OutputFormats | 52 | OutputFormats <-chan ports.OutputFormats | ||
| 51 | } | 53 | } | ||
| 52 | type Server struct { | 54 | type Server struct { | ||
| 53 | - | display *server.Display | 55 | + | nextCapture uint64 |
| 54 | - | env procEnv | 56 | + | captureReplies map[uint64]func(ports.CaptureDone) |
| 55 | - | slotsPending bool // core waits for a slot window | 57 | + | captureSources map[*server.Resource]*output |
| 56 | - | name string | 58 | + | captureSessions map[*captureSession]struct{} |
| 57 | - | cleanup func() | 59 | + | display *server.Display |
| 58 | - | log zerowrap.Logger | 60 | + | env procEnv |
| 59 | - | channels Channels | 61 | + | 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 next | 66 | // 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.Callback | 68 | awaiting map[string][]*wayland.Callback | ||
| @@ -191,6 +197,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 191 | return nil, err | 197 | 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 = ctx | 264 | s.ctx = ctx | ||
| 256 | var wg sync.WaitGroup | 265 | 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) | |||
| 88 | 88 | contents := make(chan ports.SurfaceContent, 64) | |
| 89 | 89 | cursorChanges := make(chan ports.CursorChange, 1) | |
| 90 | 90 | presented := make(chan ports.OutputPresented, 64) | |
| 91 | + | captures := make(chan ports.CaptureRequest, 32) | |
| 92 | + | captured := make(chan ports.CaptureDone, 64) | |
| 91 | 93 | outputFormats := make(chan ports.OutputFormats, 8) | |
| 92 | 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 | 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 | 105 | // Every output renders on the same GPU: its formats are the clients'. | |
| 104 | 106 | dmabuf := vulkan.Probe() | |
| 105 | 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 | 109 | if err != nil { | |
| 108 | 110 | km.Close() | |
| 109 | 111 | return err | |
| @@ -222,11 +224,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 222 | 224 | w.Sampled = dmabuf.Formats | |
| 223 | 225 | 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 | 229 | 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 | 234 | if hw != nil { | |
| @@ -244,6 +246,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 244 | 246 | } | |
| 245 | 247 | cancel() | |
| 246 | 248 | workers.Wait() | |
| 249 | + | // The wayland server has stopped: no request can be queued any more. | |
| 250 | + | drainCaptures(ctx, captures, nil) | |
| 247 | 251 | // A keymap the input goroutine never took is still ours to free. | |
| 248 | 252 | select { | |
| 249 | 253 | 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 err | 111 | return err | ||
| @@ -222,11 +224,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 222 | w.Sampled = dmabuf.Formats | 224 | w.Sampled = dmabuf.Formats | ||
| 223 | return w | 225 | 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 | return | 229 | 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() { | |||
| 109 | 109 | // one flip reader per card, and a udev watcher that rescans connectors on | |
| 110 | 110 | // hotplug. Core learns about outputs through events. It returns when ctx | |
| 111 | 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 | 113 | ctx, cancel := context.WithCancel(ctx) | |
| 114 | 114 | defer cancel() | |
| 115 | 115 | var readers sync.WaitGroup | |
| @@ -129,7 +129,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 129 | 129 | log.Warn().Err(err).Msg("hotplug watch disabled") | |
| 130 | 130 | } | |
| 131 | 131 | }() | |
| 132 | - | set := newOutputSet() | |
| 132 | + | set := newOutputSet(ctx, captured) | |
| 133 | 133 | cards := map[string]*drmCard{} | |
| 134 | 134 | send := func(ev ports.OutputEvent) { | |
| 135 | 135 | select { | |
| @@ -180,11 +180,11 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 180 | 180 | curs.set(name, cur) | |
| 181 | 181 | } | |
| 182 | 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 | 184 | defer b.seat.Unsubscribe(active) | |
| 185 | 185 | return safe("output "+name, func() error { | |
| 186 | 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 | 190 | send(ports.OutputAdded{Info: o.Info()}) | |
| @@ -209,6 +209,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 209 | 209 | set.scenes(s) | |
| 210 | 210 | case c := <-contents: | |
| 211 | 211 | set.content(c) | |
| 212 | + | case q := <-captures: | |
| 213 | + | set.routeCapture(q) | |
| 212 | 214 | case c := <-cursorChanges: | |
| 213 | 215 | set.setCursor(c) | |
| 214 | 216 | 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 on | 109 | // one flip reader per card, and a udev watcher that rescans connectors on | ||
| 110 | // hotplug. Core learns about outputs through events. It returns when ctx | 110 | // 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.WaitGroup | 115 | 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 ( | |||
| 13 | 13 | ||
| 14 | 14 | // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ... | |
| 15 | 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 { | |
| 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) | |
| 18 | 18 | for i, size := range sizes { | |
| 19 | 19 | name := fmt.Sprintf("HEADLESS-%d", i+1) | |
| 20 | 20 | dir := shots | |
| 21 | 21 | if dir != "" && len(sizes) > 1 { | |
| 22 | 22 | dir = filepath.Join(shots, name) | |
| 23 | 23 | 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()) | |
| 25 | 26 | } | |
| 26 | 27 | } | |
| 27 | 28 | cur := &headless.Cursor{} | |
| 28 | 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 | - | 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) | |
| 32 | 33 | }) | |
| 33 | 34 | select { | |
| 34 | 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 | 47 | set.scenes(s) | |
| 47 | 48 | case c := <-contents: | |
| 48 | 49 | set.content(c) | |
| 50 | + | case q := <-captures: | |
| 51 | + | set.routeCapture(q) | |
| 49 | 52 | case c := <-cursorChanges: | |
| 50 | 53 | set.setCursor(c) | |
| 51 | 54 | 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 := shots | 20 | 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 err | 24 | + | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "sync" | |
| 6 | 7 | ||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 9 | + | ||
| 7 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | 13 | // outputRun drives one output until ctx ends: it renders the scenes and | |
| 11 | 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) 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 | |
| 13 | 16 | ||
| 14 | 17 | // outputSet owns the running outputs and feeds them. Core sends one scene | |
| 15 | 18 | // 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 | 20 | // each window is kept so a new output starts with every window drawn. | |
| 18 | 21 | // Only the goroutine running loop touches the set. | |
| 19 | 22 | 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 | |
| 25 | 30 | } | |
| 26 | 31 | ||
| 27 | 32 | type runningOutput struct { | |
| 28 | 33 | scenes chan ports.Scene | |
| 29 | 34 | contents chan ports.SurfaceContent | |
| 30 | 35 | cursor chan ports.CursorChange | |
| 36 | + | captures chan ports.CaptureRequest | |
| 31 | 37 | ctx context.Context | |
| 32 | 38 | stop context.CancelFunc | |
| 33 | 39 | err error | |
| 34 | 40 | done chan struct{} | |
| 35 | 41 | } | |
| 36 | 42 | ||
| 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{})} | |
| 39 | 45 | } | |
| 40 | 46 | ||
| 41 | 47 | // start runs an output in its own goroutine and replays the window contents. | |
| 42 | 48 | func (s *outputSet) start(ctx context.Context, name string, run outputRun) { | |
| 43 | 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 | 51 | s.outs[name] = r | |
| 46 | 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 | 55 | close(r.done) | |
| 49 | 56 | select { | |
| 50 | 57 | case s.stopped <- name: | |
| @@ -110,6 +117,7 @@ func (s *outputSet) finish(name string) error { | |||
| 110 | 117 | } | |
| 111 | 118 | <-r.done | |
| 112 | 119 | r.stop() | |
| 120 | + | s.failQueued(r) | |
| 113 | 121 | delete(s.outs, name) | |
| 114 | 122 | return r.err | |
| 115 | 123 | } | |
| @@ -121,6 +129,7 @@ func (s *outputSet) wait() error { | |||
| 121 | 129 | for name, r := range s.outs { | |
| 122 | 130 | r.stop() | |
| 123 | 131 | <-r.done | |
| 132 | + | s.failQueued(r) | |
| 124 | 133 | if first == nil && r.err != nil { | |
| 125 | 134 | first = r.err | |
| 126 | 135 | } | |
| @@ -177,3 +186,47 @@ func (c *cursors) move(output string, x, y float64) { | |||
| 177 | 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 | + | } | |
| @@ -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 and | 13 | // 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) 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 |
| 13 | 16 | ||||
| 14 | // outputSet owns the running outputs and feeds them. Core sends one scene | 17 | // 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 to | 18 | // 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]*runningOutput | 23 | + | outs map[string]*runningOutput |
| 21 | - | latest map[ports.WindowID]ports.SurfaceContent | 24 | + | latest map[ports.WindowID]ports.SurfaceContent |
| 22 | - | cursor ports.CursorChange | 25 | + | cursor ports.CursorChange |
| 23 | - | stopped chan string | 26 | + | stopped chan string |
| 24 | - | quit chan struct{} // closed by wait: nobody reads stopped any more | 27 | + | 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.Scene | 33 | scenes chan ports.Scene | ||
| 29 | contents chan ports.SurfaceContent | 34 | contents chan ports.SurfaceContent | ||
| 30 | cursor chan ports.CursorChange | 35 | cursor chan ports.CursorChange | ||
| 36 | + | captures chan ports.CaptureRequest | |||
| 31 | ctx context.Context | 37 | ctx context.Context | ||
| 32 | stop context.CancelFunc | 38 | stop context.CancelFunc | ||
| 33 | err error | 39 | 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] = r | 51 | 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.done | 118 | <-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.err | 122 | 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.done | 131 | <-r.done | ||
| 132 | + | s.failQueued(r) | |||
| 124 | if first == nil && r.err != nil { | 133 | if first == nil && r.err != nil { | ||
| 125 | first = r.err | 134 | 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 ( | |||
| 12 | 12 | func TestOutputSetCursor(t *testing.T) { | |
| 13 | 13 | ctx, cancel := context.WithCancel(context.Background()) | |
| 14 | 14 | defer cancel() | |
| 15 | - | set := newOutputSet() | |
| 15 | + | set := newOutputSet(context.Background(), nil) | |
| 16 | 16 | got := map[string]chan ports.CursorChange{} | |
| 17 | 17 | run := func(name string) outputRun { | |
| 18 | 18 | got[name] = make(chan ports.CursorChange, 8) | |
| 19 | 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 | 21 | for { | |
| 22 | 22 | select { | |
| 23 | 23 | 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 { | |||
| 48 | 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 | 114 | // Close provides a mock function for the type MockRenderer | |
| 52 | 115 | func (_mock *MockRenderer) Close() { | |
| 53 | 116 | _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 MockRenderer | 114 | // 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 { | |||
| 27 | 27 | WriteCursor(i int, pixels []byte, w, h int) error | |
| 28 | 28 | // Pixels reads the last frame back (headless screenshots, tests). | |
| 29 | 29 | 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 | 32 | Close() | |
| 31 | 33 | } | |
| 32 | 34 | ||
| @@ -27,6 +27,8 @@ type Renderer interface { | |||||
| 27 | WriteCursor(i int, pixels []byte, w, h int) error | 27 | 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.RGBA | 29 | 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 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "image" | |
| 4 | 5 | "os" | |
| 5 | 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 | 29 | // ClientEvent carries wayland → core notifications. | |
| 9 | 30 | type ClientEvent interface{ clientEvent() } | |
| 10 | 31 | ||
| @@ -1,10 +1,31 @@ | |||||
| 1 | package ports | 1 | 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 | ||||